From c85193f0da6229daae31dce4b09bf6a63f616435 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 2 Oct 2025 15:09:45 +0200 Subject: [PATCH 01/19] client-api: Create `clear_database` control state method + handle parent --- crates/client-api/src/lib.rs | 6 + crates/client-api/src/routes/database.rs | 204 +-------------- .../client-api/src/routes/database/publish.rs | 234 ++++++++++++++++++ 3 files changed, 246 insertions(+), 198 deletions(-) create mode 100644 crates/client-api/src/routes/database/publish.rs diff --git a/crates/client-api/src/lib.rs b/crates/client-api/src/lib.rs index fd426bb154c..86096392dfc 100644 --- a/crates/client-api/src/lib.rs +++ b/crates/client-api/src/lib.rs @@ -169,6 +169,7 @@ pub struct DatabaseDef { pub num_replicas: Option, /// The host type of the supplied program. pub host_type: HostType, + pub parent: Option, } /// API of the SpacetimeDB control plane. @@ -239,6 +240,7 @@ pub trait ControlStateWriteAccess: Send + Sync { async fn migrate_plan(&self, spec: DatabaseDef, style: PrettyPrintStyle) -> anyhow::Result; async fn delete_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()>; + async fn clear_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()>; // Energy async fn add_energy(&self, identity: &Identity, amount: EnergyQuanta) -> anyhow::Result<()>; @@ -339,6 +341,10 @@ impl ControlStateWriteAccess for Arc { (**self).delete_database(caller_identity, database_identity).await } + async fn clear_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()> { + (**self).clear_database(caller_identity, database_identity).await + } + async fn add_energy(&self, identity: &Identity, amount: EnergyQuanta) -> anyhow::Result<()> { (**self).add_energy(identity, amount).await } diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 5ff5102d355..c2cbd13868b 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -1,4 +1,3 @@ -use std::num::NonZeroU8; use std::str::FromStr; use std::time::Duration; @@ -22,22 +21,20 @@ use spacetimedb::database_logger::DatabaseLogger; use spacetimedb::host::module_host::ClientConnectedError; use spacetimedb::host::ReducerCallError; use spacetimedb::host::ReducerOutcome; -use spacetimedb::host::UpdateDatabaseResult; use spacetimedb::host::{MigratePlanResult, ReducerArgs}; use spacetimedb::identity::Identity; use spacetimedb::messages::control_db::{Database, HostType}; -use spacetimedb_client_api_messages::name::{ - self, DatabaseName, DomainName, MigrationPolicy, PrettyPrintStyle, PrintPlanResult, PublishOp, PublishResult, -}; +use spacetimedb_client_api_messages::name::{self, DatabaseName, DomainName, PrettyPrintStyle, PrintPlanResult}; use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_lib::{sats, ProductValue, Timestamp}; -use spacetimedb_schema::auto_migrate::{ - MigrationPolicy as SchemaMigrationPolicy, MigrationToken, PrettyPrintStyle as AutoMigratePrettyPrintStyle, -}; +use spacetimedb_schema::auto_migrate::{MigrationToken, PrettyPrintStyle as AutoMigratePrettyPrintStyle}; use super::subscribe::{handle_websocket, HasWebSocketOptions}; +mod publish; +pub use publish::{publish, PublishDatabaseParams, PublishDatabaseQueryParams}; + #[derive(Deserialize)] pub struct CallParams { name_or_identity: NameOrIdentity, @@ -483,197 +480,7 @@ pub async fn get_names( Ok(axum::Json(response)) } -#[derive(Deserialize)] -pub struct PublishDatabaseParams { - name_or_identity: Option, -} - -#[derive(Deserialize)] -pub struct PublishDatabaseQueryParams { - #[serde(default)] - clear: bool, - num_replicas: Option, - /// [`Hash`] of [`MigrationToken`]` to be checked if `MigrationPolicy::BreakClients` is set. - /// - /// Users obtain such a hash via the `/database/:name_or_identity/pre-publish POST` route. - /// This is a safeguard to require explicit approval for updates which will break clients. - token: Option, - #[serde(default)] - policy: MigrationPolicy, -} - use spacetimedb_client_api_messages::http::SqlStmtResult; -use std::env; - -fn require_spacetime_auth_for_creation() -> bool { - env::var("TEMP_REQUIRE_SPACETIME_AUTH").is_ok_and(|v| !v.is_empty()) -} - -// A hacky function to let us restrict database creation on maincloud. -fn allow_creation(auth: &SpacetimeAuth) -> Result<(), ErrorResponse> { - if !require_spacetime_auth_for_creation() { - return Ok(()); - } - if auth.claims.issuer.trim_end_matches('/') == "https://auth.spacetimedb.com" { - Ok(()) - } else { - log::trace!( - "Rejecting creation request because auth issuer is {}", - auth.claims.issuer - ); - Err(( - StatusCode::UNAUTHORIZED, - "To create a database, you must be logged in with a SpacetimeDB account.", - ) - .into()) - } -} - -pub async fn publish( - State(ctx): State, - Path(PublishDatabaseParams { name_or_identity }): Path, - Query(PublishDatabaseQueryParams { - clear, - num_replicas, - token, - policy, - }): Query, - Extension(auth): Extension, - body: Bytes, -) -> axum::response::Result> { - // You should not be able to publish to a database that you do not own - // so, unless you are the owner, this will fail. - - let (database_identity, db_name) = match &name_or_identity { - Some(noa) => match noa.try_resolve(&ctx).await.map_err(log_and_500)? { - Ok(resolved) => (resolved, noa.name()), - Err(name) => { - // `name_or_identity` was a `NameOrIdentity::Name`, but no record - // exists yet. Create it now with a fresh identity. - allow_creation(&auth)?; - let database_auth = SpacetimeAuth::alloc(&ctx).await?; - let database_identity = database_auth.claims.identity; - let tld: name::Tld = name.clone().into(); - let tld = match ctx - .register_tld(&auth.claims.identity, tld) - .await - .map_err(log_and_500)? - { - name::RegisterTldResult::Success { domain } - | name::RegisterTldResult::AlreadyRegistered { domain } => domain, - name::RegisterTldResult::Unauthorized { .. } => { - return Err(( - StatusCode::UNAUTHORIZED, - axum::Json(PublishResult::PermissionDenied { name: name.clone() }), - ) - .into()) - } - }; - let res = ctx - .create_dns_record(&auth.claims.identity, &tld.into(), &database_identity) - .await - .map_err(log_and_500)?; - match res { - name::InsertDomainResult::Success { .. } => {} - name::InsertDomainResult::TldNotRegistered { .. } - | name::InsertDomainResult::PermissionDenied { .. } => { - return Err(log_and_500("impossible: we just registered the tld")) - } - name::InsertDomainResult::OtherError(e) => return Err(log_and_500(e)), - } - (database_identity, Some(name)) - } - }, - None => { - let database_auth = SpacetimeAuth::alloc(&ctx).await?; - let database_identity = database_auth.claims.identity; - (database_identity, None) - } - }; - - let policy: SchemaMigrationPolicy = match policy { - MigrationPolicy::BreakClients => { - if let Some(token) = token { - Ok(SchemaMigrationPolicy::BreakClients(token)) - } else { - Err(( - StatusCode::BAD_REQUEST, - "Migration policy is set to `BreakClients`, but no migration token was provided.", - )) - } - } - - MigrationPolicy::Compatible => Ok(SchemaMigrationPolicy::Compatible), - }?; - - log::trace!("Publishing to the identity: {}", database_identity.to_hex()); - - let op = { - let exists = ctx - .get_database_by_identity(&database_identity) - .map_err(log_and_500)? - .is_some(); - if !exists { - allow_creation(&auth)?; - } - - if clear && exists { - ctx.delete_database(&auth.claims.identity, &database_identity) - .await - .map_err(log_and_500)?; - } - - if exists { - PublishOp::Updated - } else { - PublishOp::Created - } - }; - - let num_replicas = num_replicas - .map(|n| { - let n = u8::try_from(n).map_err(|_| (StatusCode::BAD_REQUEST, "Replication factor {n} out of bounds"))?; - Ok::<_, ErrorResponse>(NonZeroU8::new(n)) - }) - .transpose()? - .flatten(); - - let maybe_updated = ctx - .publish_database( - &auth.claims.identity, - DatabaseDef { - database_identity, - program_bytes: body.into(), - num_replicas, - host_type: HostType::Wasm, - }, - policy, - ) - .await - .map_err(log_and_500)?; - - if let Some(updated) = maybe_updated { - match updated { - UpdateDatabaseResult::AutoMigrateError(errs) => { - return Err((StatusCode::BAD_REQUEST, format!("Database update rejected: {errs}")).into()); - } - UpdateDatabaseResult::ErrorExecutingMigration(err) => { - return Err(( - StatusCode::BAD_REQUEST, - format!("Failed to create or update the database: {err}"), - ) - .into()); - } - UpdateDatabaseResult::NoUpdateNeeded | UpdateDatabaseResult::UpdatePerformed => {} - } - } - - Ok(axum::Json(PublishResult::Success { - domain: db_name.cloned(), - database_identity, - op, - })) -} #[derive(serde::Deserialize)] pub struct PrePublishParams { @@ -707,6 +514,7 @@ pub async fn pre_publish( program_bytes: body.into(), num_replicas: None, host_type: HostType::Wasm, + parent: None, }, style, ) diff --git a/crates/client-api/src/routes/database/publish.rs b/crates/client-api/src/routes/database/publish.rs new file mode 100644 index 00000000000..0c10877c3f3 --- /dev/null +++ b/crates/client-api/src/routes/database/publish.rs @@ -0,0 +1,234 @@ +use std::{borrow::Cow, env, num::NonZeroU8}; + +use axum::{ + body::Bytes, + extract::{Path, Query, State}, + response::ErrorResponse, + Extension, +}; +use http::StatusCode; +use serde::Deserialize; +use spacetimedb::{host::UpdateDatabaseResult, messages::control_db::HostType, Identity}; +use spacetimedb_client_api_messages::name::{self, DatabaseName, MigrationPolicy, PublishOp, PublishResult}; +use spacetimedb_lib::Hash; +use spacetimedb_schema::auto_migrate::MigrationPolicy as SchemaMigrationPolicy; + +use crate::{auth::SpacetimeAuth, log_and_500, util::NameOrIdentity, ControlStateDelegate, DatabaseDef, NodeDelegate}; + +fn require_spacetime_auth_for_creation() -> bool { + env::var("TEMP_REQUIRE_SPACETIME_AUTH").is_ok_and(|v| !v.is_empty()) +} + +// A hacky function to let us restrict database creation on maincloud. +fn allow_creation(auth: &SpacetimeAuth) -> Result<(), ErrorResponse> { + if !require_spacetime_auth_for_creation() { + return Ok(()); + } + if auth.claims.issuer.trim_end_matches('/') == "https://auth.spacetimedb.com" { + Ok(()) + } else { + log::trace!( + "Rejecting creation request because auth issuer is {}", + auth.claims.issuer + ); + Err(( + StatusCode::UNAUTHORIZED, + "To create a database, you must be logged in with a SpacetimeDB account.", + ) + .into()) + } +} + +#[derive(Deserialize)] +pub struct PublishDatabaseParams { + name_or_identity: Option, +} + +#[derive(Deserialize)] +pub struct PublishDatabaseQueryParams { + #[serde(default)] + clear: bool, + num_replicas: Option, + /// [`Hash`] of [`MigrationToken`]` to be checked if `MigrationPolicy::BreakClients` is set. + /// + /// Users obtain such a hash via the `/database/:name_or_identity/pre-publish POST` route. + /// This is a safeguard to require explicit approval for updates which will break clients. + token: Option, + #[serde(default)] + policy: MigrationPolicy, + parent: Option, +} + +pub async fn publish( + State(ctx): State, + Path(PublishDatabaseParams { name_or_identity }): Path, + Query(PublishDatabaseQueryParams { + clear, + num_replicas, + token, + policy, + parent, + }): Query, + Extension(auth): Extension, + body: Bytes, +) -> axum::response::Result> { + let (database_identity, db_name) = get_or_create_identity_and_name(&ctx, &auth, name_or_identity.as_ref()).await?; + + log::trace!("Publishing to the identity: {}", database_identity.to_hex()); + + // Check if the database already exists. + let exists = ctx + .get_database_by_identity(&database_identity) + .map_err(log_and_500)? + .is_some(); + // If not, check that the we caller is sufficiently authenticated. + if !exists { + allow_creation(&auth)?; + } + // If the `clear` flag was given, clear the database if it exists. + // NOTE: The `clear_database` method has to check authorization. + if clear && exists { + ctx.clear_database(&auth.claims.identity, &database_identity) + .await + .map_err(log_and_500)?; + } + // Indicate in the response whether we created or updated the database. + let publish_op = if exists { PublishOp::Updated } else { PublishOp::Created }; + // Check that the replication factor looks somewhat sane. + let num_replicas = num_replicas + .map(|n| { + let n = u8::try_from(n).map_err(|_| bad_request(format!("Replication factor {n} out of bounds").into()))?; + Ok::<_, ErrorResponse>(NonZeroU8::new(n)) + }) + .transpose()? + .flatten(); + // If a parent is given, resolve to an existing database. + let parent = if let Some(name_or_identity) = parent { + let identity = name_or_identity + .resolve(&ctx) + .await + .map_err(|_| bad_request(format!("Parent database {name_or_identity} not found").into()))?; + Some(identity) + } else { + None + }; + + let schema_migration_policy = schema_migration_policy(policy, token)?; + let maybe_updated = ctx + .publish_database( + &auth.claims.identity, + DatabaseDef { + database_identity, + program_bytes: body.into(), + num_replicas, + host_type: HostType::Wasm, + parent, + }, + schema_migration_policy, + ) + .await + .map_err(log_and_500)?; + + match maybe_updated { + Some(UpdateDatabaseResult::AutoMigrateError(errs)) => { + Err(bad_request(format!("Database update rejected: {errs}").into())) + } + Some(UpdateDatabaseResult::ErrorExecutingMigration(err)) => Err(bad_request( + format!("Failed to create or update the database: {err}").into(), + )), + None | Some(UpdateDatabaseResult::NoUpdateNeeded | UpdateDatabaseResult::UpdatePerformed) => { + Ok(axum::Json(PublishResult::Success { + domain: db_name.cloned(), + database_identity, + op: publish_op, + })) + } + } +} + +/// Try to resolve `name_or_identity` to an [Identity] and [DatabaseName]. +/// +/// - If the database exists and has a name registered for it, return that. +/// - If the database does not exist, but `name_or_identity` is a name, +/// try to register the name and return alongside a newly allocated [Identity] +/// - Otherwise, if the database does not exist and `name_or_identity` is `None`, +/// allocate a fresh [Identity] and no name. +/// +async fn get_or_create_identity_and_name<'a>( + ctx: &(impl ControlStateDelegate + NodeDelegate), + auth: &SpacetimeAuth, + name_or_identity: Option<&'a NameOrIdentity>, +) -> axum::response::Result<(Identity, Option<&'a DatabaseName>)> { + match name_or_identity { + Some(noi) => match noi.try_resolve(ctx).await.map_err(log_and_500)? { + Ok(resolved) => Ok((resolved, noi.name())), + Err(name) => { + // `name_or_identity` was a `NameOrIdentity::Name`, but no record + // exists yet. Create it now with a fresh identity. + allow_creation(auth)?; + let database_auth = SpacetimeAuth::alloc(ctx).await?; + let database_identity = database_auth.claims.identity; + create_name(ctx, auth, &database_identity, name).await?; + Ok((database_identity, Some(name))) + } + }, + None => { + let database_auth = SpacetimeAuth::alloc(ctx).await?; + let database_identity = database_auth.claims.identity; + Ok((database_identity, None)) + } + } +} + +/// Try to register `name` for database `database_identity`. +async fn create_name( + ctx: &(impl NodeDelegate + ControlStateDelegate), + auth: &SpacetimeAuth, + database_identity: &Identity, + name: &DatabaseName, +) -> axum::response::Result<()> { + let tld: name::Tld = name.clone().into(); + let tld = match ctx + .register_tld(&auth.claims.identity, tld) + .await + .map_err(log_and_500)? + { + name::RegisterTldResult::Success { domain } | name::RegisterTldResult::AlreadyRegistered { domain } => domain, + name::RegisterTldResult::Unauthorized { .. } => { + return Err(( + StatusCode::UNAUTHORIZED, + axum::Json(PublishResult::PermissionDenied { name: name.clone() }), + ) + .into()) + } + }; + let res = ctx + .create_dns_record(&auth.claims.identity, &tld.into(), database_identity) + .await + .map_err(log_and_500)?; + match res { + name::InsertDomainResult::Success { .. } => Ok(()), + name::InsertDomainResult::TldNotRegistered { .. } | name::InsertDomainResult::PermissionDenied { .. } => { + Err(log_and_500("impossible: we just registered the tld")) + } + name::InsertDomainResult::OtherError(e) => Err(log_and_500(e)), + } +} + +fn schema_migration_policy( + policy: MigrationPolicy, + token: Option, +) -> axum::response::Result { + const MISSING_TOKEN: &str = "Migration policy is set to `BreakClients`, but no migration token was provided."; + + match policy { + MigrationPolicy::BreakClients => token + .map(SchemaMigrationPolicy::BreakClients) + .ok_or_else(|| bad_request(MISSING_TOKEN.into())), + MigrationPolicy::Compatible => Ok(SchemaMigrationPolicy::Compatible), + } +} + +fn bad_request(message: Cow<'static, str>) -> ErrorResponse { + (StatusCode::BAD_REQUEST, message).into() +} From a06c7cad3ef8debabf679a90a111ad34fa5cac2e Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 2 Oct 2025 15:55:35 +0200 Subject: [PATCH 02/19] cli: Add parent support to publish command --- crates/cli/src/subcommands/publish.rs | 68 +++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index df63923eb59..f1706c5eb08 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -1,9 +1,10 @@ +use anyhow::{ensure, Context}; use clap::Arg; use clap::ArgAction::{Set, SetTrue}; use clap::ArgMatches; use reqwest::{StatusCode, Url}; -use spacetimedb_client_api_messages::name::PublishOp; use spacetimedb_client_api_messages::name::{is_identity, parse_database_name, PublishResult}; +use spacetimedb_client_api_messages::name::{DatabaseNameError, PublishOp}; use std::fs; use std::path::PathBuf; @@ -58,6 +59,17 @@ pub fn cli() -> clap::Command { .arg( common_args::anonymous() ) + .arg( + Arg::new("parent") + .help("Domain or identity of a parent for this database") + .long("parent") + .long_help( +"A valid domain or identity of an existing database that should be the parent of this database. + +If a parent is given, the new database inherits the team permissions from the parent. +A parent can only be set when a database is created, not when it is updated." + ) + ) .arg( Arg::new("name|identity") .help("A valid domain or identity for this database") @@ -87,6 +99,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let database_host = config.get_host_url(server)?; let build_options = args.get_one::("build_options").unwrap(); let num_replicas = args.get_one::("num_replicas"); + let parent = args.get_one::("parent"); // If the user didn't specify an identity and we didn't specify an anonymous identity, then // we want to use the default identity @@ -96,11 +109,12 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let client = reqwest::Client::new(); - // If a domain or identity was provided, we should locally make sure it looks correct and + let (name_or_identity, parent) = + validate_name_and_parent(name_or_identity.map(String::as_str), parent.map(String::as_str))?; + + // If a name was given, ensure to percent-encode it. + // We also use PUT with a name or identity, and POST otherwise. let mut builder = if let Some(name_or_identity) = name_or_identity { - if !is_identity(name_or_identity) { - parse_database_name(name_or_identity)?; - } let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') }; let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set); client.put(format!("{database_host}/v1/database/{domain}")) @@ -164,6 +178,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E eprintln!("WARNING: Use of unstable option `--num-replicas`.\n"); builder = builder.query(&[("num_replicas", *n)]); } + if let Some(parent) = parent { + builder = builder.query(&[("parent", parent)]); + } println!("Publishing module..."); @@ -219,3 +236,44 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E Ok(()) } + +fn validate_name_or_identity(name_or_identity: &str) -> Result<(), DatabaseNameError> { + if is_identity(name_or_identity) { + Ok(()) + } else { + parse_database_name(name_or_identity).map(drop) + } +} + +fn invalid_parent_name(name: &str) -> String { + format!("invalid parent database name `{name}`") +} + +fn validate_name_and_parent<'a>( + name: Option<&'a str>, + parent: Option<&'a str>, +) -> anyhow::Result<(Option<&'a str>, Option<&'a str>)> { + if let Some(parent) = parent.as_ref() { + validate_name_or_identity(parent).with_context(|| invalid_parent_name(parent))?; + } + + match name { + Some(name) => match name.split_once('/') { + Some((parent_alt, child)) => { + ensure!( + parent.is_none() || parent.is_some_and(|parent| parent == parent_alt), + "cannot specify both --parent and /" + ); + validate_name_or_identity(parent_alt).with_context(|| invalid_parent_name(parent_alt))?; + validate_name_or_identity(child)?; + + Ok((Some(child), Some(parent_alt))) + } + None => { + validate_name_or_identity(name)?; + Ok((Some(name), parent)) + } + }, + None => Ok((None, parent)), + } +} From 767a0f9713d2874b50226758ebd6d253489493b1 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Tue, 7 Oct 2025 12:26:34 +0200 Subject: [PATCH 03/19] client-api: Make `DeleteDatabaseParams` field(s) public To allow re-use for private override. --- crates/client-api/src/routes/database.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 8f60d1fe893..87f53b2afbf 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -579,7 +579,7 @@ async fn resolve_and_authenticate( #[derive(Deserialize)] pub struct DeleteDatabaseParams { - name_or_identity: NameOrIdentity, + pub name_or_identity: NameOrIdentity, } pub async fn delete_database( From 1db2a4bfb715d3b95bf6ac7b1fd957345ce53799 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Tue, 7 Oct 2025 13:36:50 +0200 Subject: [PATCH 04/19] cli: Handle delete confirmation --- crates/cli/src/subcommands/delete.rs | 100 +++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/subcommands/delete.rs b/crates/cli/src/subcommands/delete.rs index e0d5c756137..c811833feb1 100644 --- a/crates/cli/src/subcommands/delete.rs +++ b/crates/cli/src/subcommands/delete.rs @@ -1,7 +1,13 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::io; + use crate::common_args; use crate::config::Config; -use crate::util::{add_auth_header_opt, database_identity, get_auth_header}; +use crate::util::{add_auth_header_opt, database_identity, get_auth_header, y_or_n, AuthHeader}; use clap::{Arg, ArgMatches}; +use http::StatusCode; +use reqwest::Response; +use spacetimedb_lib::{Hash, Identity}; pub fn cli() -> clap::Command { clap::Command::new("delete") @@ -22,11 +28,93 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let force = args.get_flag("force"); let identity = database_identity(&config, database, server).await?; - - let builder = reqwest::Client::new().delete(format!("{}/v1/database/{}", config.get_host_url(server)?, identity)); + let host_url = config.get_host_url(server)?; + let request_path = format!("{host_url}/v1/database/{identity}"); let auth_header = get_auth_header(&mut config, false, server, !force).await?; - let builder = add_auth_header_opt(builder, &auth_header); - builder.send().await?.error_for_status()?; + let client = reqwest::Client::new(); + + let response = send_request(&client, &request_path, &auth_header, None).await?; + match response.status() { + StatusCode::PRECONDITION_REQUIRED => { + let confirm = response.json::().await?; + println!("WARNING: Deleting the database {identity} will also delete its children:"); + confirm.print_database_tree_info(io::stdout())?; + if y_or_n(force, "Do you want to proceed deleting above databases?")? { + send_request(&client, &request_path, &auth_header, Some(confirm.token)) + .await? + .error_for_status()?; + } else { + println!("Aborting"); + } + + Ok(()) + } + StatusCode::OK => Ok(()), + _ => response.error_for_status().map(drop).map_err(Into::into), + } +} + +async fn send_request( + client: &reqwest::Client, + request_path: &str, + auth: &AuthHeader, + confirmation_token: Option, +) -> Result { + let mut builder = client.delete(request_path); + builder = add_auth_header_opt(builder, auth); + if let Some(token) = confirmation_token { + builder = builder.query(&[("token", token.to_string())]); + } + builder.send().await +} + +#[derive(serde::Deserialize)] +struct ConfirmationResponse { + database_tree: DatabaseTreeInfo, + token: Hash, +} + +impl ConfirmationResponse { + pub fn print_database_tree_info(&self, mut out: impl io::Write) -> anyhow::Result<()> { + let fmt_names = |names: &BTreeSet| match names.len() { + 0 => <_>::default(), + 1 => format!(": {}", names.first().unwrap()), + _ => format!(": {names:?}"), + }; + + let tree_info = &self.database_tree; + + write!(out, "{}{}", tree_info.root.identity, fmt_names(&tree_info.root.names))?; + for (identity, info) in &tree_info.children { + let names = fmt_names(&info.names); + let parent = info + .parent + .map(|parent| format!(" (parent: {parent})")) + .unwrap_or_default(); + + write!(out, "{identity}{parent}{names}")?; + } + + Ok(()) + } +} + +// TODO: Should below types be in client-api? + +#[derive(serde::Deserialize)] +pub struct DatabaseTreeInfo { + root: RootDatabase, + children: BTreeMap, +} + +#[derive(serde::Deserialize)] +pub struct RootDatabase { + identity: Identity, + names: BTreeSet, +} - Ok(()) +#[derive(serde::Deserialize)] +pub struct DatabaseInfo { + names: BTreeSet, + parent: Option, } From f86a5d99266292670b50835c48489816e52d033e Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 9 Oct 2025 15:03:14 +0200 Subject: [PATCH 05/19] Move publish handler back into mono-module --- crates/client-api/src/routes/database.rs | 236 ++++++++++++++++- .../client-api/src/routes/database/publish.rs | 237 ------------------ 2 files changed, 230 insertions(+), 243 deletions(-) delete mode 100644 crates/client-api/src/routes/database/publish.rs diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 87f53b2afbf..2f5fb9296fa 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -1,3 +1,6 @@ +use std::borrow::Cow; +use std::env; +use std::num::NonZeroU8; use std::str::FromStr; use std::time::Duration; @@ -19,23 +22,47 @@ use http::StatusCode; use serde::Deserialize; use spacetimedb::database_logger::DatabaseLogger; use spacetimedb::host::module_host::ClientConnectedError; -use spacetimedb::host::ReducerCallError; use spacetimedb::host::ReducerOutcome; use spacetimedb::host::{MigratePlanResult, ReducerArgs}; +use spacetimedb::host::{ReducerCallError, UpdateDatabaseResult}; use spacetimedb::identity::Identity; use spacetimedb::messages::control_db::{Database, HostType}; use spacetimedb_client_api_messages::http::SqlStmtResult; -use spacetimedb_client_api_messages::name::{self, DatabaseName, DomainName, PrePublishResult, PrettyPrintStyle}; +use spacetimedb_client_api_messages::name::{ + self, DatabaseName, DomainName, MigrationPolicy, PrePublishResult, PrettyPrintStyle, PublishOp, PublishResult, +}; use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; use spacetimedb_lib::identity::AuthCtx; -use spacetimedb_lib::{sats, ProductValue, Timestamp}; -use spacetimedb_schema::auto_migrate::{MigrationToken, PrettyPrintStyle as AutoMigratePrettyPrintStyle}; +use spacetimedb_lib::{sats, Hash, ProductValue, Timestamp}; +use spacetimedb_schema::auto_migrate::{ + MigrationPolicy as SchemaMigrationPolicy, MigrationToken, PrettyPrintStyle as AutoMigratePrettyPrintStyle, +}; use super::subscribe::{handle_websocket, HasWebSocketOptions}; -mod publish; -pub use publish::{publish, PublishDatabaseParams, PublishDatabaseQueryParams}; +fn require_spacetime_auth_for_creation() -> bool { + env::var("TEMP_REQUIRE_SPACETIME_AUTH").is_ok_and(|v| !v.is_empty()) +} +// A hacky function to let us restrict database creation on maincloud. +fn allow_creation(auth: &SpacetimeAuth) -> Result<(), ErrorResponse> { + if !require_spacetime_auth_for_creation() { + return Ok(()); + } + if auth.claims.issuer.trim_end_matches('/') == "https://auth.spacetimedb.com" { + Ok(()) + } else { + log::trace!( + "Rejecting creation request because auth issuer is {}", + auth.claims.issuer + ); + Err(( + StatusCode::UNAUTHORIZED, + "To create a database, you must be logged in with a SpacetimeDB account.", + ) + .into()) + } +} #[derive(Deserialize)] pub struct CallParams { name_or_identity: NameOrIdentity, @@ -481,6 +508,203 @@ pub async fn get_names( Ok(axum::Json(response)) } +#[derive(Deserialize)] +pub struct PublishDatabaseParams { + name_or_identity: Option, +} + +#[derive(Deserialize)] +pub struct PublishDatabaseQueryParams { + #[serde(default)] + clear: bool, + num_replicas: Option, + /// [`Hash`] of [`MigrationToken`]` to be checked if `MigrationPolicy::BreakClients` is set. + /// + /// Users obtain such a hash via the `/database/:name_or_identity/pre-publish POST` route. + /// This is a safeguard to require explicit approval for updates which will break clients. + token: Option, + #[serde(default)] + policy: MigrationPolicy, + parent: Option, +} + +pub async fn publish( + State(ctx): State, + Path(PublishDatabaseParams { name_or_identity }): Path, + Query(PublishDatabaseQueryParams { + clear, + num_replicas, + token, + policy, + parent, + }): Query, + Extension(auth): Extension, + body: Bytes, +) -> axum::response::Result> { + let (database_identity, db_name) = get_or_create_identity_and_name(&ctx, &auth, name_or_identity.as_ref()).await?; + + log::trace!("Publishing to the identity: {}", database_identity.to_hex()); + + // Check if the database already exists. + let exists = ctx + .get_database_by_identity(&database_identity) + .map_err(log_and_500)? + .is_some(); + // If not, check that the we caller is sufficiently authenticated. + if !exists { + allow_creation(&auth)?; + } + // If the `clear` flag was given, clear the database if it exists. + // NOTE: The `clear_database` method has to check authorization. + if clear && exists { + ctx.clear_database(&auth.claims.identity, &database_identity) + .await + .map_err(log_and_500)?; + } + // Indicate in the response whether we created or updated the database. + let publish_op = if exists { PublishOp::Updated } else { PublishOp::Created }; + // Check that the replication factor looks somewhat sane. + let num_replicas = num_replicas + .map(|n| { + let n = u8::try_from(n).map_err(|_| bad_request(format!("Replication factor {n} out of bounds").into()))?; + Ok::<_, ErrorResponse>(NonZeroU8::new(n)) + }) + .transpose()? + .flatten(); + // If a parent is given, resolve to an existing database. + let parent = if let Some(name_or_identity) = parent { + let identity = name_or_identity + .resolve(&ctx) + .await + .map_err(|_| bad_request(format!("Parent database {name_or_identity} not found").into()))?; + Some(identity) + } else { + None + }; + + let schema_migration_policy = schema_migration_policy(policy, token)?; + let maybe_updated = ctx + .publish_database( + &auth.claims.identity, + DatabaseDef { + database_identity, + program_bytes: body.into(), + num_replicas, + host_type: HostType::Wasm, + parent, + }, + schema_migration_policy, + ) + .await + .map_err(log_and_500)?; + + match maybe_updated { + Some(UpdateDatabaseResult::AutoMigrateError(errs)) => { + Err(bad_request(format!("Database update rejected: {errs}").into())) + } + Some(UpdateDatabaseResult::ErrorExecutingMigration(err)) => Err(bad_request( + format!("Failed to create or update the database: {err}").into(), + )), + None + | Some( + UpdateDatabaseResult::NoUpdateNeeded + | UpdateDatabaseResult::UpdatePerformed + | UpdateDatabaseResult::UpdatePerformedWithClientDisconnect, + ) => Ok(axum::Json(PublishResult::Success { + domain: db_name.cloned(), + database_identity, + op: publish_op, + })), + } +} + +/// Try to resolve `name_or_identity` to an [Identity] and [DatabaseName]. +/// +/// - If the database exists and has a name registered for it, return that. +/// - If the database does not exist, but `name_or_identity` is a name, +/// try to register the name and return alongside a newly allocated [Identity] +/// - Otherwise, if the database does not exist and `name_or_identity` is `None`, +/// allocate a fresh [Identity] and no name. +/// +async fn get_or_create_identity_and_name<'a>( + ctx: &(impl ControlStateDelegate + NodeDelegate), + auth: &SpacetimeAuth, + name_or_identity: Option<&'a NameOrIdentity>, +) -> axum::response::Result<(Identity, Option<&'a DatabaseName>)> { + match name_or_identity { + Some(noi) => match noi.try_resolve(ctx).await.map_err(log_and_500)? { + Ok(resolved) => Ok((resolved, noi.name())), + Err(name) => { + // `name_or_identity` was a `NameOrIdentity::Name`, but no record + // exists yet. Create it now with a fresh identity. + allow_creation(auth)?; + let database_auth = SpacetimeAuth::alloc(ctx).await?; + let database_identity = database_auth.claims.identity; + create_name(ctx, auth, &database_identity, name).await?; + Ok((database_identity, Some(name))) + } + }, + None => { + let database_auth = SpacetimeAuth::alloc(ctx).await?; + let database_identity = database_auth.claims.identity; + Ok((database_identity, None)) + } + } +} + +/// Try to register `name` for database `database_identity`. +async fn create_name( + ctx: &(impl NodeDelegate + ControlStateDelegate), + auth: &SpacetimeAuth, + database_identity: &Identity, + name: &DatabaseName, +) -> axum::response::Result<()> { + let tld: name::Tld = name.clone().into(); + let tld = match ctx + .register_tld(&auth.claims.identity, tld) + .await + .map_err(log_and_500)? + { + name::RegisterTldResult::Success { domain } | name::RegisterTldResult::AlreadyRegistered { domain } => domain, + name::RegisterTldResult::Unauthorized { .. } => { + return Err(( + StatusCode::UNAUTHORIZED, + axum::Json(PublishResult::PermissionDenied { name: name.clone() }), + ) + .into()) + } + }; + let res = ctx + .create_dns_record(&auth.claims.identity, &tld.into(), database_identity) + .await + .map_err(log_and_500)?; + match res { + name::InsertDomainResult::Success { .. } => Ok(()), + name::InsertDomainResult::TldNotRegistered { .. } | name::InsertDomainResult::PermissionDenied { .. } => { + Err(log_and_500("impossible: we just registered the tld")) + } + name::InsertDomainResult::OtherError(e) => Err(log_and_500(e)), + } +} + +fn schema_migration_policy( + policy: MigrationPolicy, + token: Option, +) -> axum::response::Result { + const MISSING_TOKEN: &str = "Migration policy is set to `BreakClients`, but no migration token was provided."; + + match policy { + MigrationPolicy::BreakClients => token + .map(SchemaMigrationPolicy::BreakClients) + .ok_or_else(|| bad_request(MISSING_TOKEN.into())), + MigrationPolicy::Compatible => Ok(SchemaMigrationPolicy::Compatible), + } +} + +fn bad_request(message: Cow<'static, str>) -> ErrorResponse { + (StatusCode::BAD_REQUEST, message).into() +} + #[derive(serde::Deserialize)] pub struct PrePublishParams { name_or_identity: NameOrIdentity, diff --git a/crates/client-api/src/routes/database/publish.rs b/crates/client-api/src/routes/database/publish.rs deleted file mode 100644 index c697fc3179f..00000000000 --- a/crates/client-api/src/routes/database/publish.rs +++ /dev/null @@ -1,237 +0,0 @@ -use std::{borrow::Cow, env, num::NonZeroU8}; - -use axum::{ - body::Bytes, - extract::{Path, Query, State}, - response::ErrorResponse, - Extension, -}; -use http::StatusCode; -use serde::Deserialize; -use spacetimedb::{host::UpdateDatabaseResult, messages::control_db::HostType, Identity}; -use spacetimedb_client_api_messages::name::{self, DatabaseName, MigrationPolicy, PublishOp, PublishResult}; -use spacetimedb_lib::Hash; -use spacetimedb_schema::auto_migrate::MigrationPolicy as SchemaMigrationPolicy; - -use crate::{auth::SpacetimeAuth, log_and_500, util::NameOrIdentity, ControlStateDelegate, DatabaseDef, NodeDelegate}; - -fn require_spacetime_auth_for_creation() -> bool { - env::var("TEMP_REQUIRE_SPACETIME_AUTH").is_ok_and(|v| !v.is_empty()) -} - -// A hacky function to let us restrict database creation on maincloud. -fn allow_creation(auth: &SpacetimeAuth) -> Result<(), ErrorResponse> { - if !require_spacetime_auth_for_creation() { - return Ok(()); - } - if auth.claims.issuer.trim_end_matches('/') == "https://auth.spacetimedb.com" { - Ok(()) - } else { - log::trace!( - "Rejecting creation request because auth issuer is {}", - auth.claims.issuer - ); - Err(( - StatusCode::UNAUTHORIZED, - "To create a database, you must be logged in with a SpacetimeDB account.", - ) - .into()) - } -} - -#[derive(Deserialize)] -pub struct PublishDatabaseParams { - name_or_identity: Option, -} - -#[derive(Deserialize)] -pub struct PublishDatabaseQueryParams { - #[serde(default)] - clear: bool, - num_replicas: Option, - /// [`Hash`] of [`MigrationToken`]` to be checked if `MigrationPolicy::BreakClients` is set. - /// - /// Users obtain such a hash via the `/database/:name_or_identity/pre-publish POST` route. - /// This is a safeguard to require explicit approval for updates which will break clients. - token: Option, - #[serde(default)] - policy: MigrationPolicy, - parent: Option, -} - -pub async fn publish( - State(ctx): State, - Path(PublishDatabaseParams { name_or_identity }): Path, - Query(PublishDatabaseQueryParams { - clear, - num_replicas, - token, - policy, - parent, - }): Query, - Extension(auth): Extension, - body: Bytes, -) -> axum::response::Result> { - let (database_identity, db_name) = get_or_create_identity_and_name(&ctx, &auth, name_or_identity.as_ref()).await?; - - log::trace!("Publishing to the identity: {}", database_identity.to_hex()); - - // Check if the database already exists. - let exists = ctx - .get_database_by_identity(&database_identity) - .map_err(log_and_500)? - .is_some(); - // If not, check that the we caller is sufficiently authenticated. - if !exists { - allow_creation(&auth)?; - } - // If the `clear` flag was given, clear the database if it exists. - // NOTE: The `clear_database` method has to check authorization. - if clear && exists { - ctx.clear_database(&auth.claims.identity, &database_identity) - .await - .map_err(log_and_500)?; - } - // Indicate in the response whether we created or updated the database. - let publish_op = if exists { PublishOp::Updated } else { PublishOp::Created }; - // Check that the replication factor looks somewhat sane. - let num_replicas = num_replicas - .map(|n| { - let n = u8::try_from(n).map_err(|_| bad_request(format!("Replication factor {n} out of bounds").into()))?; - Ok::<_, ErrorResponse>(NonZeroU8::new(n)) - }) - .transpose()? - .flatten(); - // If a parent is given, resolve to an existing database. - let parent = if let Some(name_or_identity) = parent { - let identity = name_or_identity - .resolve(&ctx) - .await - .map_err(|_| bad_request(format!("Parent database {name_or_identity} not found").into()))?; - Some(identity) - } else { - None - }; - - let schema_migration_policy = schema_migration_policy(policy, token)?; - let maybe_updated = ctx - .publish_database( - &auth.claims.identity, - DatabaseDef { - database_identity, - program_bytes: body.into(), - num_replicas, - host_type: HostType::Wasm, - parent, - }, - schema_migration_policy, - ) - .await - .map_err(log_and_500)?; - - match maybe_updated { - Some(UpdateDatabaseResult::AutoMigrateError(errs)) => { - Err(bad_request(format!("Database update rejected: {errs}").into())) - } - Some(UpdateDatabaseResult::ErrorExecutingMigration(err)) => Err(bad_request( - format!("Failed to create or update the database: {err}").into(), - )), - None - | Some( - UpdateDatabaseResult::NoUpdateNeeded - | UpdateDatabaseResult::UpdatePerformed - | UpdateDatabaseResult::UpdatePerformedWithClientDisconnect, - ) => Ok(axum::Json(PublishResult::Success { - domain: db_name.cloned(), - database_identity, - op: publish_op, - })), - } -} - -/// Try to resolve `name_or_identity` to an [Identity] and [DatabaseName]. -/// -/// - If the database exists and has a name registered for it, return that. -/// - If the database does not exist, but `name_or_identity` is a name, -/// try to register the name and return alongside a newly allocated [Identity] -/// - Otherwise, if the database does not exist and `name_or_identity` is `None`, -/// allocate a fresh [Identity] and no name. -/// -async fn get_or_create_identity_and_name<'a>( - ctx: &(impl ControlStateDelegate + NodeDelegate), - auth: &SpacetimeAuth, - name_or_identity: Option<&'a NameOrIdentity>, -) -> axum::response::Result<(Identity, Option<&'a DatabaseName>)> { - match name_or_identity { - Some(noi) => match noi.try_resolve(ctx).await.map_err(log_and_500)? { - Ok(resolved) => Ok((resolved, noi.name())), - Err(name) => { - // `name_or_identity` was a `NameOrIdentity::Name`, but no record - // exists yet. Create it now with a fresh identity. - allow_creation(auth)?; - let database_auth = SpacetimeAuth::alloc(ctx).await?; - let database_identity = database_auth.claims.identity; - create_name(ctx, auth, &database_identity, name).await?; - Ok((database_identity, Some(name))) - } - }, - None => { - let database_auth = SpacetimeAuth::alloc(ctx).await?; - let database_identity = database_auth.claims.identity; - Ok((database_identity, None)) - } - } -} - -/// Try to register `name` for database `database_identity`. -async fn create_name( - ctx: &(impl NodeDelegate + ControlStateDelegate), - auth: &SpacetimeAuth, - database_identity: &Identity, - name: &DatabaseName, -) -> axum::response::Result<()> { - let tld: name::Tld = name.clone().into(); - let tld = match ctx - .register_tld(&auth.claims.identity, tld) - .await - .map_err(log_and_500)? - { - name::RegisterTldResult::Success { domain } | name::RegisterTldResult::AlreadyRegistered { domain } => domain, - name::RegisterTldResult::Unauthorized { .. } => { - return Err(( - StatusCode::UNAUTHORIZED, - axum::Json(PublishResult::PermissionDenied { name: name.clone() }), - ) - .into()) - } - }; - let res = ctx - .create_dns_record(&auth.claims.identity, &tld.into(), database_identity) - .await - .map_err(log_and_500)?; - match res { - name::InsertDomainResult::Success { .. } => Ok(()), - name::InsertDomainResult::TldNotRegistered { .. } | name::InsertDomainResult::PermissionDenied { .. } => { - Err(log_and_500("impossible: we just registered the tld")) - } - name::InsertDomainResult::OtherError(e) => Err(log_and_500(e)), - } -} - -fn schema_migration_policy( - policy: MigrationPolicy, - token: Option, -) -> axum::response::Result { - const MISSING_TOKEN: &str = "Migration policy is set to `BreakClients`, but no migration token was provided."; - - match policy { - MigrationPolicy::BreakClients => token - .map(SchemaMigrationPolicy::BreakClients) - .ok_or_else(|| bad_request(MISSING_TOKEN.into())), - MigrationPolicy::Compatible => Ok(SchemaMigrationPolicy::Compatible), - } -} - -fn bad_request(message: Cow<'static, str>) -> ErrorResponse { - (StatusCode::BAD_REQUEST, message).into() -} From 8250080f55b719d1227aa7dd46baeff8fba78531 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 10 Oct 2025 13:32:10 +0200 Subject: [PATCH 06/19] Move shared types to client-api-messages and render database tree using termtree --- Cargo.lock | 8 ++ Cargo.toml | 1 + crates/cli/Cargo.toml | 4 + crates/cli/src/subcommands/delete.rs | 144 +++++++++++++++++-------- crates/client-api-messages/src/http.rs | 22 +++- 5 files changed, 132 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa6cb645241..4485b944367 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5804,6 +5804,7 @@ dependencies = [ "itertools 0.12.1", "mimalloc", "percent-encoding", + "pretty_assertions", "regex", "reqwest 0.12.15", "rustyline", @@ -5826,6 +5827,7 @@ dependencies = [ "tar", "tempfile", "termcolor", + "termtree", "thiserror 1.0.69", "tikv-jemalloc-ctl", "tikv-jemallocator", @@ -7137,6 +7139,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test-client" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index fc69357fc57..51b5e236a62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -267,6 +267,7 @@ tar = "0.4" tempdir = "0.3.7" tempfile = "3.20" termcolor = "1.2.0" +termtree = "0.5.1" thin-vec = "0.2.13" thiserror = "1.0.37" tokio = { version = "1.37", features = ["full"] } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1ec691324ad..e602139d7e9 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -64,6 +64,7 @@ tabled.workspace = true tar.workspace = true tempfile.workspace = true termcolor.workspace = true +termtree.workspace = true thiserror.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true @@ -75,6 +76,9 @@ wasmbin.workspace = true webbrowser.workspace = true clap-markdown.workspace = true +[dev-dependencies] +pretty_assertions.workspace = true + [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = { workspace = true } tikv-jemalloc-ctl = { workspace = true } diff --git a/crates/cli/src/subcommands/delete.rs b/crates/cli/src/subcommands/delete.rs index c811833feb1..9818707b128 100644 --- a/crates/cli/src/subcommands/delete.rs +++ b/crates/cli/src/subcommands/delete.rs @@ -1,4 +1,3 @@ -use std::collections::{BTreeMap, BTreeSet}; use std::io; use crate::common_args; @@ -6,8 +5,11 @@ use crate::config::Config; use crate::util::{add_auth_header_opt, database_identity, get_auth_header, y_or_n, AuthHeader}; use clap::{Arg, ArgMatches}; use http::StatusCode; +use itertools::Itertools as _; use reqwest::Response; -use spacetimedb_lib::{Hash, Identity}; +use spacetimedb_client_api_messages::http::{DatabaseDeleteConfirmationResponse, DatabaseTree, DatabaseTreeNode}; +use spacetimedb_lib::Hash; +use tokio::io::AsyncWriteExt as _; pub fn cli() -> clap::Command { clap::Command::new("delete") @@ -36,11 +38,13 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let response = send_request(&client, &request_path, &auth_header, None).await?; match response.status() { StatusCode::PRECONDITION_REQUIRED => { - let confirm = response.json::().await?; - println!("WARNING: Deleting the database {identity} will also delete its children:"); - confirm.print_database_tree_info(io::stdout())?; + let confirm = response.json::().await?; + println!("WARNING: Deleting the database {identity} will also delete its children!"); + if !force { + print_database_tree_info(&confirm.database_tree).await?; + } if y_or_n(force, "Do you want to proceed deleting above databases?")? { - send_request(&client, &request_path, &auth_header, Some(confirm.token)) + send_request(&client, &request_path, &auth_header, Some(confirm.confirmation_token)) .await? .error_for_status()?; } else { @@ -68,53 +72,101 @@ async fn send_request( builder.send().await } -#[derive(serde::Deserialize)] -struct ConfirmationResponse { - database_tree: DatabaseTreeInfo, - token: Hash, +async fn print_database_tree_info(tree: &DatabaseTree) -> io::Result<()> { + tokio::io::stdout() + .write_all(as_termtree(tree).to_string().as_bytes()) + .await } -impl ConfirmationResponse { - pub fn print_database_tree_info(&self, mut out: impl io::Write) -> anyhow::Result<()> { - let fmt_names = |names: &BTreeSet| match names.len() { - 0 => <_>::default(), - 1 => format!(": {}", names.first().unwrap()), - _ => format!(": {names:?}"), - }; - - let tree_info = &self.database_tree; - - write!(out, "{}{}", tree_info.root.identity, fmt_names(&tree_info.root.names))?; - for (identity, info) in &tree_info.children { - let names = fmt_names(&info.names); - let parent = info - .parent - .map(|parent| format!(" (parent: {parent})")) - .unwrap_or_default(); - - write!(out, "{identity}{parent}{names}")?; +fn as_termtree(tree: &DatabaseTree) -> termtree::Tree { + let mut stack: Vec<(&DatabaseTree, bool)> = vec![]; + stack.push((tree, false)); + + let mut built: Vec> = <_>::default(); + + while let Some((node, visited)) = stack.pop() { + if visited { + let mut term_node = termtree::Tree::new(fmt_tree_node(&node.root)); + term_node.leaves = built.drain(built.len() - node.children.len()..).collect(); + term_node.leaves.reverse(); + built.push(term_node); + } else { + stack.push((node, true)); + stack.extend(node.children.iter().rev().map(|child| (child, false))); } - - Ok(()) } -} - -// TODO: Should below types be in client-api? -#[derive(serde::Deserialize)] -pub struct DatabaseTreeInfo { - root: RootDatabase, - children: BTreeMap, + built + .pop() + .expect("database tree contains a root and we pushed it last") } -#[derive(serde::Deserialize)] -pub struct RootDatabase { - identity: Identity, - names: BTreeSet, +fn fmt_tree_node(node: &DatabaseTreeNode) -> String { + format!( + "{}{}", + node.database_identity, + if node.database_names.is_empty() { + <_>::default() + } else { + format!(": {}", node.database_names.iter().join(", ")) + } + ) } -#[derive(serde::Deserialize)] -pub struct DatabaseInfo { - names: BTreeSet, - parent: Option, +#[cfg(test)] +mod tests { + use super::*; + use spacetimedb_client_api_messages::http::{DatabaseTree, DatabaseTreeNode}; + use spacetimedb_lib::{sats::u256, Identity}; + + #[test] + fn render_termtree() { + let tree = DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::ONE, + database_names: ["parent".into()].into(), + }, + children: vec![ + DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::from_u256(u256::new(2)), + database_names: ["child".into()].into(), + }, + children: vec![ + DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::from_u256(u256::new(3)), + database_names: ["grandchild".into()].into(), + }, + children: vec![], + }, + DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::from_u256(u256::new(5)), + database_names: [].into(), + }, + children: vec![], + }, + ], + }, + DatabaseTree { + root: DatabaseTreeNode { + database_identity: Identity::from_u256(u256::new(4)), + database_names: ["sibling".into(), "bro".into()].into(), + }, + children: vec![], + }, + ], + }; + pretty_assertions::assert_eq!( + "\ +0000000000000000000000000000000000000000000000000000000000000001: parent +├── 0000000000000000000000000000000000000000000000000000000000000004: bro, sibling +└── 0000000000000000000000000000000000000000000000000000000000000002: child + ├── 0000000000000000000000000000000000000000000000000000000000000005 + └── 0000000000000000000000000000000000000000000000000000000000000003: grandchild +", + &as_termtree(&tree).to_string() + ); + } } diff --git a/crates/client-api-messages/src/http.rs b/crates/client-api-messages/src/http.rs index fe966bf5dfc..052539d551a 100644 --- a/crates/client-api-messages/src/http.rs +++ b/crates/client-api-messages/src/http.rs @@ -1,6 +1,8 @@ +use std::collections::BTreeSet; + use serde::{Deserialize, Serialize}; use spacetimedb_lib::metrics::ExecutionMetrics; -use spacetimedb_lib::ProductType; +use spacetimedb_lib::{Hash, Identity, ProductType}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SqlStmtResult { @@ -27,3 +29,21 @@ impl SqlStmtStats { } } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DatabaseTree { + pub root: DatabaseTreeNode, + pub children: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DatabaseTreeNode { + pub database_identity: Identity, + pub database_names: BTreeSet, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct DatabaseDeleteConfirmationResponse { + pub database_tree: DatabaseTree, + pub confirmation_token: Hash, +} From faba13582875b557046ad9ff9b729be1f010f1b2 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 10 Oct 2025 18:04:09 +0200 Subject: [PATCH 07/19] Fix token serialization --- crates/cli/src/subcommands/delete.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cli/src/subcommands/delete.rs b/crates/cli/src/subcommands/delete.rs index 9818707b128..b05e5d205cd 100644 --- a/crates/cli/src/subcommands/delete.rs +++ b/crates/cli/src/subcommands/delete.rs @@ -67,7 +67,7 @@ async fn send_request( let mut builder = client.delete(request_path); builder = add_auth_header_opt(builder, auth); if let Some(token) = confirmation_token { - builder = builder.query(&[("token", token.to_string())]); + builder = builder.query(&[("token", token)]); } builder.send().await } From 59584aea95a24376b4212d5a007309d3d3234aa5 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 10 Oct 2025 18:04:20 +0200 Subject: [PATCH 08/19] Add DatabaseTree iterator --- crates/client-api-messages/src/http.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/client-api-messages/src/http.rs b/crates/client-api-messages/src/http.rs index 052539d551a..02cc75968e6 100644 --- a/crates/client-api-messages/src/http.rs +++ b/crates/client-api-messages/src/http.rs @@ -1,4 +1,5 @@ use std::collections::BTreeSet; +use std::iter; use serde::{Deserialize, Serialize}; use spacetimedb_lib::metrics::ExecutionMetrics; @@ -36,6 +37,19 @@ pub struct DatabaseTree { pub children: Vec, } +impl DatabaseTree { + pub fn iter(&self) -> impl Iterator + '_ { + let mut stack = vec![self]; + iter::from_fn(move || { + let node = stack.pop()?; + for child in node.children.iter().rev() { + stack.push(child); + } + Some(&node.root) + }) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct DatabaseTreeNode { pub database_identity: Identity, From f1d6afe70153b1d5b9a3d167e507b50a0dfa62ae Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 10 Oct 2025 18:05:05 +0200 Subject: [PATCH 09/19] Add simple smoketest --- .github/workflows/ci.yml | 2 +- smoketests/__init__.py | 4 ++-- smoketests/tests/teams.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 smoketests/tests/teams.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa33bcf152c..da3e0a8f019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: run: python -m pip install psycopg2-binary - name: Run smoketests # Note: clear_database and replication only work in private - run: python -m smoketests ${{ matrix.smoketest_args }} -x clear_database replication + run: python -m smoketests ${{ matrix.smoketest_args }} -x clear_database replication teams - name: Stop containers (Linux) if: always() && runner.os == 'Linux' run: docker compose down diff --git a/smoketests/__init__.py b/smoketests/__init__.py index 63486c09b08..2cf3bae62b9 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -365,7 +365,7 @@ def tearDown(self): if "database_identity" in self.__dict__: try: # TODO: save the credentials in publish_module() - self.spacetime("delete", self.database_identity) + self.spacetime("delete", "--yes", self.database_identity) except Exception: pass @@ -374,7 +374,7 @@ def tearDownClass(cls): if hasattr(cls, "database_identity"): try: # TODO: save the credentials in publish_module() - cls.spacetime("delete", cls.database_identity) + cls.spacetime("delete", "--yes", cls.database_identity) except Exception: pass diff --git a/smoketests/tests/teams.py b/smoketests/tests/teams.py new file mode 100644 index 00000000000..2734b147cc5 --- /dev/null +++ b/smoketests/tests/teams.py @@ -0,0 +1,33 @@ +from .. import Smoketest, parse_sql_result, random_string + +class CreateChildDatabase(Smoketest): + AUTOPUBLISH = False + + def test_create_child_database(self): + """ + Test that the owner can add a child database + """ + + parent_name = random_string() + child_name = random_string() + + self.publish_module(parent_name) + parent_identity = self.database_identity + self.publish_module(f"{parent_name}/{child_name}") + child_identity = self.database_identity + + databases = self.query_controldb(parent_identity, child_identity) + self.assertEqual(2, len(databases)) + + self.spacetime("delete", "--yes", parent_name) + + databases = self.query_controldb(parent_identity, child_identity) + self.assertEqual(0, len(databases)) + + def query_controldb(self, parent, child): + res = self.spacetime( + "sql", + "spacetime-control", + f"select * from database where database_identity = 0x{parent} or database_identity = 0x{child}" + ) + return parse_sql_result(str(res)) From 7c51072d5c06ecd56db9027aec938d6badf3ca35 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 10 Oct 2025 18:18:26 +0200 Subject: [PATCH 10/19] Regen CLI docs --- docs/docs/cli-reference.md | 237 ++++++++++++++++++++++++------------- 1 file changed, 156 insertions(+), 81 deletions(-) diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index 11045a959f1..14c44f7dda4 100644 --- a/docs/docs/cli-reference.md +++ b/docs/docs/cli-reference.md @@ -34,11 +34,11 @@ This document contains the help content for the `spacetime` command-line program * [`spacetime start`↴](#spacetime-start) * [`spacetime version`↴](#spacetime-version) -## spacetime +## `spacetime` **Usage:** `spacetime [OPTIONS] ` -###### Subcommands: +###### **Subcommands:** * `publish` — Create and update a SpacetimeDB database * `delete` — Deletes a SpacetimeDB database @@ -51,7 +51,7 @@ This document contains the help content for the `spacetime` command-line program * `generate` — Generate client files for a spacetime module. * `list` — Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. * `login` — Manage your login to the SpacetimeDB CLI -* `logout` — +* `logout` — * `init` — Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. * `build` — Builds a spacetime module. * `server` — Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. @@ -59,12 +59,14 @@ This document contains the help content for the `spacetime` command-line program * `start` — Start a local SpacetimeDB instance * `version` — Manage installed spacetime versions -###### Options: +###### **Options:** * `--root-dir ` — The root directory to store all spacetime files in. * `--config-path ` — The path to the cli.toml config file -## spacetime publish + + +## `spacetime publish` Create and update a SpacetimeDB database @@ -72,19 +74,19 @@ Create and update a SpacetimeDB database Run `spacetime help publish` for more detailed information. -###### Arguments: +###### **Arguments:** * `` — A valid domain or identity for this database. - Database names must match the regex `/^[a-z0-9]+(-[a-z0-9]+)*$/`, - i.e. only lowercase ASCII letters and numbers, separated by dashes. + Database names must match the regex `/^[a-z0-9]+(-[a-z0-9]+)*$/`, + i.e. only lowercase ASCII letters and numbers, separated by dashes. -###### Options: +###### **Options:** * `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the module * `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' - Default value: \`\` + Default value: `` * `-p`, `--project-path ` — The system path (absolute or relative) to the module project Default value: `.` @@ -92,10 +94,16 @@ Run `spacetime help publish` for more detailed information. * `-j`, `--js-path ` — UNSTABLE: The system path (absolute or relative) to the javascript file we should publish, instead of building the project. * `--break-clients` — Allow breaking changes when publishing to an existing database identity. This will break existing clients. * `--anonymous` — Perform this action with an anonymous identity +* `--parent ` — A valid domain or identity of an existing database that should be the parent of this database. + + If a parent is given, the new database inherits the team permissions from the parent. + A parent can only be set when a database is created, not when it is updated. * `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime delete + + +## `spacetime delete` Deletes a SpacetimeDB database @@ -103,16 +111,19 @@ Deletes a SpacetimeDB database Run `spacetime help delete` for more detailed information. -###### Arguments: + +###### **Arguments:** * `` — The name or identity of the database to delete -###### Options: +###### **Options:** * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime logs + + +## `spacetime logs` Prints logs from a SpacetimeDB database @@ -120,11 +131,12 @@ Prints logs from a SpacetimeDB database Run `spacetime help logs` for more detailed information. -###### Arguments: + +###### **Arguments:** * `` — The name or identity of the database to print logs from -###### Options: +###### **Options:** * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-n`, `--num-lines ` — The number of lines to print from the start of the log of this database. If no num lines is provided, all lines will be returned. @@ -134,9 +146,12 @@ Run `spacetime help logs` for more detailed information. Default value: `text` Possible values: `text`, `json` + * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime call + + +## `spacetime call` Invokes a reducer function in a database. WARNING: This command is UNSTABLE and subject to breaking changes. @@ -144,19 +159,22 @@ Invokes a reducer function in a database. WARNING: This command is UNSTABLE and Run `spacetime help call` for more detailed information. -###### Arguments: + +###### **Arguments:** * `` — The database name or identity to use to invoke the call * `` — The name of the reducer to call * `` — arguments formatted as JSON -###### Options: +###### **Options:** * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `--anonymous` — Perform this action with an anonymous identity * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime describe + + +## `spacetime describe` Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. @@ -164,56 +182,64 @@ Describe the structure of a database or entities within it. WARNING: This comman Run `spacetime help describe` for more detailed information. -###### Arguments: + +###### **Arguments:** * `` — The name or identity of the database to describe * `` — Whether to describe a reducer or table Possible values: `reducer`, `table` + * `` — The name of the entity to describe -###### Options: +###### **Options:** * `--json` — Output the schema in JSON format. Currently required; in the future, omitting this will give human-readable output. * `--anonymous` — Perform this action with an anonymous identity * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime energy + + +## `spacetime energy` Invokes commands related to database budgets. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime energy energy ` -###### Subcommands: +###### **Subcommands:** * `balance` — Show current energy balance for an identity -## spacetime energy balance + + +## `spacetime energy balance` Show current energy balance for an identity **Usage:** `spacetime energy balance [OPTIONS]` -###### Options: +###### **Options:** * `-i`, `--identity ` — The identity to check the balance for. If no identity is provided, the default one will be used. * `-s`, `--server ` — The nickname, host name or URL of the server from which to request balance information * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime sql + + +## `spacetime sql` Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime sql [OPTIONS] ` -###### Arguments: +###### **Arguments:** * `` — The name or identity of the database you would like to query * `` — The SQL query to execute -###### Options: +###### **Options:** * `--interactive` — Instead of using a query, run an interactive command prompt for `SQL` expressions * `--confirmed` — Instruct the server to deliver only updates of confirmed transactions @@ -221,7 +247,9 @@ Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime rename + + +## `spacetime rename` Rename a database @@ -229,17 +257,20 @@ Rename a database Run `spacetime rename --help` for more detailed information. -###### Arguments: + +###### **Arguments:** * `` — The database identity to rename -###### Options: +###### **Options:** * `--to ` — The new name you would like to assign * `-s`, `--server ` — The nickname, host name or URL of the server on which to set the name * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime generate + + +## `spacetime generate` Generate client files for a spacetime module. @@ -247,7 +278,7 @@ Generate client files for a spacetime module. Run `spacetime help publish` for more detailed information. -###### Options: +###### **Options:** * `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should inspect * `-p`, `--project-path ` — The system path (absolute or relative) to the project you would like to inspect @@ -262,34 +293,39 @@ Run `spacetime help publish` for more detailed information. * `-l`, `--lang ` — The language to generate Possible values: `csharp`, `typescript`, `rust`, `unrealcpp` + * `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' - Default value: \`\` + Default value: `` * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime list + + +## `spacetime list` Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime list [OPTIONS]` -###### Options: +###### **Options:** * `-s`, `--server ` — The nickname, host name or URL of the server from which to list databases * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime login + + +## `spacetime login` Manage your login to the SpacetimeDB CLI **Usage:** `spacetime login [OPTIONS] login ` -###### Subcommands: +###### **Subcommands:** * `show` — Show the current login info -###### Options: +###### **Options:** * `--auth-host ` — Fetch login token from a different host @@ -297,51 +333,60 @@ Manage your login to the SpacetimeDB CLI * `--server-issued-login ` — Log in to a SpacetimeDB server directly, without going through a global auth server * `--token ` — Bypass the login flow and use a login token directly -## spacetime login show + + +## `spacetime login show` Show the current login info **Usage:** `spacetime login show [OPTIONS]` -###### Options: +###### **Options:** * `--token` — Also show the auth token -## spacetime logout + + +## `spacetime logout` **Usage:** `spacetime logout [OPTIONS]` -###### Options: +###### **Options:** * `--auth-host ` — Log out from a custom auth server Default value: `https://spacetimedb.com` -## spacetime init + + +## `spacetime init` Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime init --lang [project-path]` -###### Arguments: +###### **Arguments:** * `` — The path where we will create the spacetime project Default value: `.` -###### Options: +###### **Options:** * `-l`, `--lang ` — The spacetime module language. Possible values: `csharp`, `rust` -## spacetime build + + + +## `spacetime build` Builds a spacetime module. **Usage:** `spacetime build [OPTIONS]` -###### Options: +###### **Options:** * `-p`, `--project-path ` — The system path (absolute or relative) to the project you would like to build @@ -351,14 +396,16 @@ Builds a spacetime module. Default value: `src` * `-d`, `--debug` — Builds the module using debug instead of release (intended to speed up local iteration, not recommended for CI) -## spacetime server + + +## `spacetime server` Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime server server ` -###### Subcommands: +###### **Subcommands:** * `list` — List stored server configurations * `set-default` — Set the default server for future operations @@ -369,127 +416,147 @@ Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTAB * `edit` — Update a saved server's nickname, host name or protocol * `clear` — Deletes all data from all local databases -## spacetime server list + + +## `spacetime server list` List stored server configurations **Usage:** `spacetime server list` -## spacetime server set-default + + +## `spacetime server set-default` Set the default server for future operations **Usage:** `spacetime server set-default ` -###### Arguments: +###### **Arguments:** * `` — The nickname, host name or URL of the new default server -## spacetime server add + + +## `spacetime server add` Add a new server configuration **Usage:** `spacetime server add [OPTIONS] --url ` -###### Arguments: +###### **Arguments:** * `` — Nickname for this server -###### Options: +###### **Options:** * `--url ` — The URL of the server to add * `-d`, `--default` — Make the new server the default server for future operations * `--no-fingerprint` — Skip fingerprinting the server -## spacetime server remove + + +## `spacetime server remove` Remove a saved server configuration **Usage:** `spacetime server remove [OPTIONS] ` -###### Arguments: +###### **Arguments:** * `` — The nickname, host name or URL of the server to remove -###### Options: +###### **Options:** * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime server fingerprint + + +## `spacetime server fingerprint` Show or update a saved server's fingerprint **Usage:** `spacetime server fingerprint [OPTIONS] ` -###### Arguments: +###### **Arguments:** * `` — The nickname, host name or URL of the server -###### Options: +###### **Options:** * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime server ping + + +## `spacetime server ping` Checks to see if a SpacetimeDB host is online **Usage:** `spacetime server ping ` -###### Arguments: +###### **Arguments:** * `` — The nickname, host name or URL of the server to ping -## spacetime server edit + + +## `spacetime server edit` Update a saved server's nickname, host name or protocol **Usage:** `spacetime server edit [OPTIONS] ` -###### Arguments: +###### **Arguments:** * `` — The nickname, host name or URL of the server -###### Options: +###### **Options:** * `--new-name ` — A new nickname to assign the server configuration * `--url ` — A new URL to assign the server configuration * `--no-fingerprint` — Skip fingerprinting the server * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime server clear + + +## `spacetime server clear` Deletes all data from all local databases **Usage:** `spacetime server clear [OPTIONS]` -###### Options: +###### **Options:** -* `--data-dir ` — The path to the server data directory to clear \[default: that of the selected spacetime instance] +* `--data-dir ` — The path to the server data directory to clear [default: that of the selected spacetime instance] * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). -## spacetime subscribe + + +## `spacetime subscribe` Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime subscribe [OPTIONS] ...` -###### Arguments: +###### **Arguments:** * `` — The name or identity of the database you would like to query * `` — The SQL query to execute -###### Options: +###### **Options:** * `-n`, `--num-updates ` — The number of subscription updates to receive before exiting * `-t`, `--timeout ` — The timeout, in seconds, after which to disconnect and stop receiving subscription messages. If `-n` is specified, it will stop after whichever - one comes first. + one comes first. * `--print-initial-update` — Print the initial update for the queries. * `--confirmed` — Instruct the server to deliver only updates of confirmed transactions * `--anonymous` — Perform this action with an anonymous identity * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database -## spacetime start + + +## `spacetime start` Start a local SpacetimeDB instance @@ -497,11 +564,11 @@ Run `spacetime start --help` to see all options. **Usage:** `spacetime start [OPTIONS] [args]...` -###### Arguments: +###### **Arguments:** * `` — The args to pass to `spacetimedb-{edition} start` -###### Options: +###### **Options:** * `--edition ` — The edition of SpacetimeDB to start up @@ -509,7 +576,10 @@ Run `spacetime start --help` to see all options. Possible values: `standalone`, `cloud` -## spacetime version + + + +## `spacetime version` Manage installed spacetime versions @@ -517,11 +587,16 @@ Run `spacetime version --help` to see all options. **Usage:** `spacetime version [ARGS]...` -###### Arguments: +###### **Arguments:** * `` — The args to pass to spacetimedb-update + +
-This document was generated automatically by clap-markdown. + This document was generated automatically by + clap-markdown. + + From ca3d1a8950010d80ce8bd9c95c435b1ad613c7fd Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 10 Oct 2025 18:25:38 +0200 Subject: [PATCH 11/19] fixup! Regen CLI docs --- docs/docs/cli-reference.md | 237 +++++++++++++------------------------ 1 file changed, 83 insertions(+), 154 deletions(-) diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index 14c44f7dda4..94860911632 100644 --- a/docs/docs/cli-reference.md +++ b/docs/docs/cli-reference.md @@ -34,11 +34,11 @@ This document contains the help content for the `spacetime` command-line program * [`spacetime start`↴](#spacetime-start) * [`spacetime version`↴](#spacetime-version) -## `spacetime` +## spacetime **Usage:** `spacetime [OPTIONS] ` -###### **Subcommands:** +###### Subcommands: * `publish` — Create and update a SpacetimeDB database * `delete` — Deletes a SpacetimeDB database @@ -51,7 +51,7 @@ This document contains the help content for the `spacetime` command-line program * `generate` — Generate client files for a spacetime module. * `list` — Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. * `login` — Manage your login to the SpacetimeDB CLI -* `logout` — +* `logout` — * `init` — Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. * `build` — Builds a spacetime module. * `server` — Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. @@ -59,14 +59,12 @@ This document contains the help content for the `spacetime` command-line program * `start` — Start a local SpacetimeDB instance * `version` — Manage installed spacetime versions -###### **Options:** +###### Options: * `--root-dir ` — The root directory to store all spacetime files in. * `--config-path ` — The path to the cli.toml config file - - -## `spacetime publish` +## spacetime publish Create and update a SpacetimeDB database @@ -74,19 +72,19 @@ Create and update a SpacetimeDB database Run `spacetime help publish` for more detailed information. -###### **Arguments:** +###### Arguments: * `` — A valid domain or identity for this database. - Database names must match the regex `/^[a-z0-9]+(-[a-z0-9]+)*$/`, - i.e. only lowercase ASCII letters and numbers, separated by dashes. + Database names must match the regex `/^[a-z0-9]+(-[a-z0-9]+)*$/`, + i.e. only lowercase ASCII letters and numbers, separated by dashes. -###### **Options:** +###### Options: * `-c`, `--delete-data` — When publishing to an existing database identity, first DESTROY all data associated with the module * `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' - Default value: `` + Default value: \`\` * `-p`, `--project-path ` — The system path (absolute or relative) to the module project Default value: `.` @@ -96,14 +94,12 @@ Run `spacetime help publish` for more detailed information. * `--anonymous` — Perform this action with an anonymous identity * `--parent ` — A valid domain or identity of an existing database that should be the parent of this database. - If a parent is given, the new database inherits the team permissions from the parent. - A parent can only be set when a database is created, not when it is updated. + If a parent is given, the new database inherits the team permissions from the parent. + A parent can only be set when a database is created, not when it is updated. * `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime delete` +## spacetime delete Deletes a SpacetimeDB database @@ -111,19 +107,16 @@ Deletes a SpacetimeDB database Run `spacetime help delete` for more detailed information. - -###### **Arguments:** +###### Arguments: * `` — The name or identity of the database to delete -###### **Options:** +###### Options: * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime logs` +## spacetime logs Prints logs from a SpacetimeDB database @@ -131,12 +124,11 @@ Prints logs from a SpacetimeDB database Run `spacetime help logs` for more detailed information. - -###### **Arguments:** +###### Arguments: * `` — The name or identity of the database to print logs from -###### **Options:** +###### Options: * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-n`, `--num-lines ` — The number of lines to print from the start of the log of this database. If no num lines is provided, all lines will be returned. @@ -146,12 +138,9 @@ Run `spacetime help logs` for more detailed information. Default value: `text` Possible values: `text`, `json` - * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime call` +## spacetime call Invokes a reducer function in a database. WARNING: This command is UNSTABLE and subject to breaking changes. @@ -159,22 +148,19 @@ Invokes a reducer function in a database. WARNING: This command is UNSTABLE and Run `spacetime help call` for more detailed information. - -###### **Arguments:** +###### Arguments: * `` — The database name or identity to use to invoke the call * `` — The name of the reducer to call * `` — arguments formatted as JSON -###### **Options:** +###### Options: * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `--anonymous` — Perform this action with an anonymous identity * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime describe` +## spacetime describe Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. @@ -182,64 +168,56 @@ Describe the structure of a database or entities within it. WARNING: This comman Run `spacetime help describe` for more detailed information. - -###### **Arguments:** +###### Arguments: * `` — The name or identity of the database to describe * `` — Whether to describe a reducer or table Possible values: `reducer`, `table` - * `` — The name of the entity to describe -###### **Options:** +###### Options: * `--json` — Output the schema in JSON format. Currently required; in the future, omitting this will give human-readable output. * `--anonymous` — Perform this action with an anonymous identity * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime energy` +## spacetime energy Invokes commands related to database budgets. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime energy energy ` -###### **Subcommands:** +###### Subcommands: * `balance` — Show current energy balance for an identity - - -## `spacetime energy balance` +## spacetime energy balance Show current energy balance for an identity **Usage:** `spacetime energy balance [OPTIONS]` -###### **Options:** +###### Options: * `-i`, `--identity ` — The identity to check the balance for. If no identity is provided, the default one will be used. * `-s`, `--server ` — The nickname, host name or URL of the server from which to request balance information * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime sql` +## spacetime sql Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime sql [OPTIONS] ` -###### **Arguments:** +###### Arguments: * `` — The name or identity of the database you would like to query * `` — The SQL query to execute -###### **Options:** +###### Options: * `--interactive` — Instead of using a query, run an interactive command prompt for `SQL` expressions * `--confirmed` — Instruct the server to deliver only updates of confirmed transactions @@ -247,9 +225,7 @@ Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime rename` +## spacetime rename Rename a database @@ -257,20 +233,17 @@ Rename a database Run `spacetime rename --help` for more detailed information. - -###### **Arguments:** +###### Arguments: * `` — The database identity to rename -###### **Options:** +###### Options: * `--to ` — The new name you would like to assign * `-s`, `--server ` — The nickname, host name or URL of the server on which to set the name * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime generate` +## spacetime generate Generate client files for a spacetime module. @@ -278,7 +251,7 @@ Generate client files for a spacetime module. Run `spacetime help publish` for more detailed information. -###### **Options:** +###### Options: * `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should inspect * `-p`, `--project-path ` — The system path (absolute or relative) to the project you would like to inspect @@ -293,39 +266,34 @@ Run `spacetime help publish` for more detailed information. * `-l`, `--lang ` — The language to generate Possible values: `csharp`, `typescript`, `rust`, `unrealcpp` - * `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' - Default value: `` + Default value: \`\` * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime list` +## spacetime list Lists the databases attached to an identity. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime list [OPTIONS]` -###### **Options:** +###### Options: * `-s`, `--server ` — The nickname, host name or URL of the server from which to list databases * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime login` +## spacetime login Manage your login to the SpacetimeDB CLI **Usage:** `spacetime login [OPTIONS] login ` -###### **Subcommands:** +###### Subcommands: * `show` — Show the current login info -###### **Options:** +###### Options: * `--auth-host ` — Fetch login token from a different host @@ -333,60 +301,51 @@ Manage your login to the SpacetimeDB CLI * `--server-issued-login ` — Log in to a SpacetimeDB server directly, without going through a global auth server * `--token ` — Bypass the login flow and use a login token directly - - -## `spacetime login show` +## spacetime login show Show the current login info **Usage:** `spacetime login show [OPTIONS]` -###### **Options:** +###### Options: * `--token` — Also show the auth token - - -## `spacetime logout` +## spacetime logout **Usage:** `spacetime logout [OPTIONS]` -###### **Options:** +###### Options: * `--auth-host ` — Log out from a custom auth server Default value: `https://spacetimedb.com` - - -## `spacetime init` +## spacetime init Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime init --lang [project-path]` -###### **Arguments:** +###### Arguments: * `` — The path where we will create the spacetime project Default value: `.` -###### **Options:** +###### Options: * `-l`, `--lang ` — The spacetime module language. Possible values: `csharp`, `rust` - - - -## `spacetime build` +## spacetime build Builds a spacetime module. **Usage:** `spacetime build [OPTIONS]` -###### **Options:** +###### Options: * `-p`, `--project-path ` — The system path (absolute or relative) to the project you would like to build @@ -396,16 +355,14 @@ Builds a spacetime module. Default value: `src` * `-d`, `--debug` — Builds the module using debug instead of release (intended to speed up local iteration, not recommended for CI) - - -## `spacetime server` +## spacetime server Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime server server ` -###### **Subcommands:** +###### Subcommands: * `list` — List stored server configurations * `set-default` — Set the default server for future operations @@ -416,147 +373,127 @@ Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTAB * `edit` — Update a saved server's nickname, host name or protocol * `clear` — Deletes all data from all local databases - - -## `spacetime server list` +## spacetime server list List stored server configurations **Usage:** `spacetime server list` - - -## `spacetime server set-default` +## spacetime server set-default Set the default server for future operations **Usage:** `spacetime server set-default ` -###### **Arguments:** +###### Arguments: * `` — The nickname, host name or URL of the new default server - - -## `spacetime server add` +## spacetime server add Add a new server configuration **Usage:** `spacetime server add [OPTIONS] --url ` -###### **Arguments:** +###### Arguments: * `` — Nickname for this server -###### **Options:** +###### Options: * `--url ` — The URL of the server to add * `-d`, `--default` — Make the new server the default server for future operations * `--no-fingerprint` — Skip fingerprinting the server - - -## `spacetime server remove` +## spacetime server remove Remove a saved server configuration **Usage:** `spacetime server remove [OPTIONS] ` -###### **Arguments:** +###### Arguments: * `` — The nickname, host name or URL of the server to remove -###### **Options:** +###### Options: * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime server fingerprint` +## spacetime server fingerprint Show or update a saved server's fingerprint **Usage:** `spacetime server fingerprint [OPTIONS] ` -###### **Arguments:** +###### Arguments: * `` — The nickname, host name or URL of the server -###### **Options:** +###### Options: * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime server ping` +## spacetime server ping Checks to see if a SpacetimeDB host is online **Usage:** `spacetime server ping ` -###### **Arguments:** +###### Arguments: * `` — The nickname, host name or URL of the server to ping - - -## `spacetime server edit` +## spacetime server edit Update a saved server's nickname, host name or protocol **Usage:** `spacetime server edit [OPTIONS] ` -###### **Arguments:** +###### Arguments: * `` — The nickname, host name or URL of the server -###### **Options:** +###### Options: * `--new-name ` — A new nickname to assign the server configuration * `--url ` — A new URL to assign the server configuration * `--no-fingerprint` — Skip fingerprinting the server * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime server clear` +## spacetime server clear Deletes all data from all local databases **Usage:** `spacetime server clear [OPTIONS]` -###### **Options:** +###### Options: -* `--data-dir ` — The path to the server data directory to clear [default: that of the selected spacetime instance] +* `--data-dir ` — The path to the server data directory to clear \[default: that of the selected spacetime instance] * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). - - -## `spacetime subscribe` +## spacetime subscribe Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime subscribe [OPTIONS] ...` -###### **Arguments:** +###### Arguments: * `` — The name or identity of the database you would like to query * `` — The SQL query to execute -###### **Options:** +###### Options: * `-n`, `--num-updates ` — The number of subscription updates to receive before exiting * `-t`, `--timeout ` — The timeout, in seconds, after which to disconnect and stop receiving subscription messages. If `-n` is specified, it will stop after whichever - one comes first. + one comes first. * `--print-initial-update` — Print the initial update for the queries. * `--confirmed` — Instruct the server to deliver only updates of confirmed transactions * `--anonymous` — Perform this action with an anonymous identity * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database - - -## `spacetime start` +## spacetime start Start a local SpacetimeDB instance @@ -564,11 +501,11 @@ Run `spacetime start --help` to see all options. **Usage:** `spacetime start [OPTIONS] [args]...` -###### **Arguments:** +###### Arguments: * `` — The args to pass to `spacetimedb-{edition} start` -###### **Options:** +###### Options: * `--edition ` — The edition of SpacetimeDB to start up @@ -576,10 +513,7 @@ Run `spacetime start --help` to see all options. Possible values: `standalone`, `cloud` - - - -## `spacetime version` +## spacetime version Manage installed spacetime versions @@ -587,16 +521,11 @@ Run `spacetime version --help` to see all options. **Usage:** `spacetime version [ARGS]...` -###### **Arguments:** +###### Arguments: * `` — The args to pass to spacetimedb-update - -
- This document was generated automatically by - clap-markdown. - - +This document was generated automatically by clap-markdown. From 61f72ee1707e2d967716ca5e9d099ebf375defcf Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Mon, 13 Oct 2025 09:28:48 +0200 Subject: [PATCH 12/19] Debug and fix database names smoke test --- crates/cli/src/subcommands/publish.rs | 64 +++++++++++++++++++++++++++ smoketests/tests/domains.py | 3 -- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 0c96c27940e..b195a13f90a 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -395,3 +395,67 @@ async fn call_pre_publish( let pre_publish_result: PrePublishResult = res.json_or_error().await?; Ok(Some(pre_publish_result)) } + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_matches; + use spacetimedb_lib::Identity; + + use super::*; + + #[test] + fn validate_none_arguments_returns_none_values() { + assert_matches!(validate_name_and_parent(None, None), Ok((None, None))); + assert_matches!(validate_name_and_parent(Some("foo"), None), Ok((Some(_), None))); + assert_matches!(validate_name_and_parent(None, Some("foo")), Ok((None, Some(_)))); + } + + #[test] + fn validate_valid_arguments_returns_arguments() { + let name = "child"; + let parent = "parent"; + let result = (Some(name), Some(parent)); + assert_matches!( + validate_name_and_parent(Some(name), Some(parent)), + Ok(val) if val == result + ); + } + + #[test] + fn validate_parent_and_path_name_returns_error_unless_parent_equal() { + assert_matches!( + validate_name_and_parent(Some("parent/child"), Some("parent")), + Ok((Some("child"), Some("parent"))) + ); + assert_matches!(validate_name_and_parent(Some("parent/child"), Some("cousin")), Err(_)); + } + + #[test] + fn validate_more_than_two_path_segments_are_an_error() { + assert_matches!(validate_name_and_parent(Some("proc/net/tcp"), None), Err(_)); + assert_matches!(validate_name_and_parent(Some("proc//net"), None), Err(_)); + } + + #[test] + fn validate_trailing_slash_is_an_error() { + assert_matches!(validate_name_and_parent(Some("foo//"), None), Err(_)); + assert_matches!(validate_name_and_parent(Some("foo/bar/"), None), Err(_)); + } + + #[test] + fn validate_parent_cant_have_slash() { + assert_matches!(validate_name_and_parent(Some("child"), Some("par/ent")), Err(_)); + assert_matches!(validate_name_and_parent(Some("child"), Some("parent/")), Err(_)); + } + + #[test] + fn validate_name_or_parent_can_be_identities() { + let parent = Identity::ZERO.to_string(); + let child = Identity::ONE.to_string(); + + assert_matches!( + validate_name_and_parent(Some(&child), Some(&parent)), + Ok(res) if res == (Some(&child), Some(&parent)) + ); + } +} diff --git a/smoketests/tests/domains.py b/smoketests/tests/domains.py index 61fef422f2f..ca1def69d59 100644 --- a/smoketests/tests/domains.py +++ b/smoketests/tests/domains.py @@ -1,5 +1,4 @@ from .. import Smoketest, random_string -import unittest import json class Domains(Smoketest): @@ -26,13 +25,11 @@ def test_set_name(self): with self.assertRaises(Exception): self.spacetime("logs", orig_name) - @unittest.expectedFailure def test_subdomain_behavior(self): """Test how we treat the / character in published names""" root_name = random_string() self.publish_module(root_name) - id_to_rename = self.database_identity self.publish_module(f"{root_name}/test") From 0a0966c3d639d60b12412ac1def75763efbf9067 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Tue, 21 Oct 2025 16:33:23 +0200 Subject: [PATCH 13/19] smoketests + big arse changes to make clear database + sql work Accessing private tables in subscription queries still don't work --- Cargo.lock | 1 + crates/cli/src/subcommands/publish.rs | 18 +- crates/cli/src/util.rs | 9 - crates/client-api/Cargo.toml | 1 + crates/client-api/src/lib.rs | 91 +++++- crates/client-api/src/routes/database.rs | 266 +++++++++++----- crates/client-api/src/routes/mod.rs | 4 +- crates/client-api/src/util.rs | 4 +- crates/core/src/sql/ast.rs | 7 +- crates/core/src/sql/execute.rs | 8 +- .../core/src/subscription/execution_unit.rs | 5 +- .../subscription/module_subscription_actor.rs | 4 +- crates/core/src/subscription/query.rs | 4 +- crates/core/src/subscription/subscription.rs | 15 +- crates/core/src/vm.rs | 4 +- crates/expr/src/check.rs | 2 +- crates/expr/src/rls.rs | 4 +- crates/expr/src/statement.rs | 2 +- crates/lib/src/identity.rs | 73 ++++- crates/pg/src/pg_server.rs | 26 +- crates/schema/src/def/error.rs | 4 +- crates/standalone/src/control_db.rs | 19 +- crates/standalone/src/lib.rs | 111 ++++--- crates/vm/src/expr.rs | 47 ++- smoketests/tests/teams.py | 297 +++++++++++++++++- 25 files changed, 797 insertions(+), 229 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4485b944367..0b9e5756b36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5885,6 +5885,7 @@ dependencies = [ "spacetimedb-paths", "spacetimedb-schema", "tempfile", + "thiserror 1.0.69", "tokio", "tokio-stream", "tokio-tungstenite", diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index b195a13f90a..22e6033c268 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -10,7 +10,7 @@ use std::{env, fs}; use crate::config::Config; use crate::util::{add_auth_header_opt, get_auth_header, AuthHeader, ResponseExt}; -use crate::util::{decode_identity, unauth_error_context, y_or_n}; +use crate::util::{decode_identity, y_or_n}; use crate::{build, common_args}; pub fn cli() -> clap::Command { @@ -187,7 +187,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E break_clients_flag, ) .await?; - }; + } builder } else { @@ -229,18 +229,6 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E builder = builder.query(&[("host_type", host_type)]); let res = builder.body(program_bytes).send().await?; - if res.status() == StatusCode::UNAUTHORIZED && !anon_identity { - // If we're not in the `anon_identity` case, then we have already forced the user to log in above (using `get_auth_header`), so this should be safe to unwrap. - let token = config.spacetimedb_token().unwrap(); - let identity = decode_identity(token)?; - let err = res.text().await?; - return unauth_error_context( - Err(anyhow::anyhow!(err)), - &identity, - config.server_nick_or_host(server)?, - ); - } - let response: PublishResult = res.json_or_error().await?; match response { PublishResult::Success { @@ -341,7 +329,7 @@ async fn apply_pre_publish_if_needed( auth_header: &AuthHeader, break_clients_flag: bool, ) -> Result { - if let Some(pre) = call_pre_publish(client, base_url, &domain.to_string(), program_bytes, auth_header).await? { + if let Some(pre) = call_pre_publish(client, base_url, domain, program_bytes, auth_header).await? { println!("{}", pre.migrate_plan); if pre.break_clients diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 7161d4c9fec..d8071739c28 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -268,15 +268,6 @@ pub fn y_or_n(force: bool, prompt: &str) -> anyhow::Result { Ok(input == "y" || input == "yes") } -pub fn unauth_error_context(res: anyhow::Result, identity: &str, server: &str) -> anyhow::Result { - res.with_context(|| { - format!( - "Identity {identity} is not valid for server {server}. -Please log back in with `spacetime logout` and then `spacetime login`." - ) - }) -} - pub fn decode_identity(token: &String) -> anyhow::Result { // Here, we manually extract and decode the claims from the json web token. // We do this without using the `jsonwebtoken` crate because it doesn't seem to have a way to skip signature verification. diff --git a/crates/client-api/Cargo.toml b/crates/client-api/Cargo.toml index be32c90e9c3..59c9e636d94 100644 --- a/crates/client-api/Cargo.toml +++ b/crates/client-api/Cargo.toml @@ -53,6 +53,7 @@ scopeguard.workspace = true serde_with.workspace = true async-stream.workspace = true humantime.workspace = true +thiserror.workspace = true [target.'cfg(not(target_env = "msvc"))'.dependencies] jemalloc_pprof.workspace = true diff --git a/crates/client-api/src/lib.rs b/crates/client-api/src/lib.rs index 86096392dfc..b78db0ae985 100644 --- a/crates/client-api/src/lib.rs +++ b/crates/client-api/src/lib.rs @@ -1,8 +1,11 @@ +use std::future::Future; use std::num::NonZeroU8; use std::sync::Arc; +use anyhow::anyhow; use async_trait::async_trait; use axum::response::ErrorResponse; +use bytes::Bytes; use http::StatusCode; use spacetimedb::client::ClientActorIndex; @@ -16,6 +19,7 @@ use spacetimedb_client_api_messages::name::{DomainName, InsertDomainResult, Regi use spacetimedb_lib::{ProductTypeElement, ProductValue}; use spacetimedb_paths::server::ModuleLogsDir; use spacetimedb_schema::auto_migrate::{MigrationPolicy, PrettyPrintStyle}; +use thiserror::Error; use tokio::sync::watch; pub mod auth; @@ -162,7 +166,7 @@ pub struct DatabaseDef { /// The [`Identity`] the database shall have. pub database_identity: Identity, /// The compiled program of the database module. - pub program_bytes: Vec, + pub program_bytes: Bytes, /// The desired number of replicas the database shall have. /// /// If `None`, the edition default is used. @@ -172,6 +176,14 @@ pub struct DatabaseDef { pub parent: Option, } +/// Parameters for resetting a database via [`ControlStateDelegate::clear_database`]. +pub struct DatabaseResetDef { + pub database_identity: Identity, + pub program_bytes: Option, + pub num_replicas: Option, + pub host_type: Option, +} + /// API of the SpacetimeDB control plane. /// /// The trait is the composition of [`ControlStateReadAccess`] and @@ -240,7 +252,10 @@ pub trait ControlStateWriteAccess: Send + Sync { async fn migrate_plan(&self, spec: DatabaseDef, style: PrettyPrintStyle) -> anyhow::Result; async fn delete_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()>; - async fn clear_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()>; + + /// Remove all data from a database, and reset it according to the + /// given [DatabaseResetDef]. + async fn reset_database(&self, caller_identity: &Identity, spec: DatabaseResetDef) -> anyhow::Result<()>; // Energy async fn add_energy(&self, identity: &Identity, amount: EnergyQuanta) -> anyhow::Result<()>; @@ -341,8 +356,8 @@ impl ControlStateWriteAccess for Arc { (**self).delete_database(caller_identity, database_identity).await } - async fn clear_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()> { - (**self).clear_database(caller_identity, database_identity).await + async fn reset_database(&self, caller_identity: &Identity, spec: DatabaseResetDef) -> anyhow::Result<()> { + (**self).reset_database(caller_identity, spec).await } async fn add_energy(&self, identity: &Identity, amount: EnergyQuanta) -> anyhow::Result<()> { @@ -401,6 +416,74 @@ impl NodeDelegate for Arc { } } +#[derive(Debug, Error)] +pub enum Unauthorized { + #[error("{subject} is not authorized to perform {action:?}")] + Unauthorized { + subject: Identity, + action: Action, + #[source] + source: Option, + }, + #[error("authorization failed due to internal error")] + InternalError(#[from] anyhow::Error), +} + +impl Unauthorized { + pub fn into_response(self) -> ErrorResponse { + match self { + unauthorized @ Self::Unauthorized { .. } => { + (StatusCode::UNAUTHORIZED, format!("{:#}", anyhow!(unauthorized))).into() + } + Self::InternalError(e) => log_and_500(e), + } + } +} + +#[derive(Debug)] +pub enum Action { + CreateDatabase { parent: Option }, + UpdateDatabase, + ResetDatabase, + DeleteDatabase, + RenameDatabase, + ViewModuleLogs, +} + +pub trait Authorization { + fn authorize_action( + &self, + subject: Identity, + database: Identity, + action: Action, + ) -> impl Future> + Send; + + fn authorize_sql( + &self, + subject: Identity, + database: Identity, + ) -> impl Future> + Send; +} + +impl Authorization for Arc { + fn authorize_action( + &self, + subject: Identity, + database: Identity, + action: Action, + ) -> impl Future> + Send { + (**self).authorize_action(subject, database, action) + } + + fn authorize_sql( + &self, + subject: Identity, + database: Identity, + ) -> impl Future> + Send { + (**self).authorize_sql(subject, database) + } +} + pub fn log_and_500(e: impl std::fmt::Display) -> ErrorResponse { log::error!("internal error: {e:#}"); (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into() diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 0de0617e849..cbe429fe886 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -10,7 +10,9 @@ use crate::auth::{ }; use crate::routes::subscribe::generate_random_connection_id; pub use crate::util::{ByteStringBody, NameOrIdentity}; -use crate::{log_and_500, ControlStateDelegate, DatabaseDef, NodeDelegate}; +use crate::{ + log_and_500, Action, Authorization, ControlStateDelegate, DatabaseDef, DatabaseResetDef, NodeDelegate, Unauthorized, +}; use axum::body::{Body, Bytes}; use axum::extract::{Path, Query, State}; use axum::response::{ErrorResponse, IntoResponse}; @@ -32,7 +34,6 @@ use spacetimedb_client_api_messages::name::{ self, DatabaseName, DomainName, MigrationPolicy, PrePublishResult, PrettyPrintStyle, PublishOp, PublishResult, }; use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9; -use spacetimedb_lib::identity::AuthCtx; use spacetimedb_lib::{sats, Hash, ProductValue, Timestamp}; use spacetimedb_schema::auto_migrate::{ MigrationPolicy as SchemaMigrationPolicy, MigrationToken, PrettyPrintStyle as AutoMigratePrettyPrintStyle, @@ -320,7 +321,7 @@ pub async fn logs( Extension(auth): Extension, ) -> axum::response::Result where - S: ControlStateDelegate + NodeDelegate, + S: ControlStateDelegate + NodeDelegate + Authorization, { // You should not be able to read the logs from a database that you do not own // so, unless you are the owner, this will fail. @@ -330,17 +331,10 @@ where .await? .ok_or(NO_SUCH_DATABASE)?; - if database.owner_identity != auth.claims.identity { - return Err(( - StatusCode::BAD_REQUEST, - format!( - "Identity does not own database, expected: {} got: {}", - database.owner_identity.to_hex(), - auth.claims.identity.to_hex() - ), - ) - .into()); - } + worker_ctx + .authorize_action(auth.claims.identity, database.database_identity, Action::ViewModuleLogs) + .await + .map_err(Unauthorized::into_response)?; let replica = worker_ctx .get_leader_replica_by_database(database.id) @@ -427,7 +421,7 @@ pub async fn sql_direct( sql: String, ) -> axum::response::Result>> where - S: NodeDelegate + ControlStateDelegate, + S: NodeDelegate + ControlStateDelegate + Authorization, { // Anyone is authorized to execute SQL queries. The SQL engine will determine // which queries this identity is allowed to execute against the database. @@ -437,8 +431,10 @@ where .await? .ok_or(NO_SUCH_DATABASE)?; - let auth = AuthCtx::new(database.owner_identity, caller_identity); - log::debug!("auth: {auth:?}"); + let auth = worker_ctx + .authorize_sql(caller_identity, database.database_identity) + .await + .map_err(Unauthorized::into_response)?; let host = worker_ctx .leader(database.id) @@ -457,7 +453,7 @@ pub async fn sql( body: String, ) -> axum::response::Result where - S: NodeDelegate + ControlStateDelegate, + S: NodeDelegate + ControlStateDelegate + Authorization, { let json = sql_direct(worker_ctx, name_or_identity, params, auth.claims.identity, body).await?; @@ -508,6 +504,59 @@ pub async fn get_names( Ok(axum::Json(response)) } +#[derive(Deserialize)] +pub struct ResetDatabaseParams { + name_or_identity: NameOrIdentity, +} + +#[derive(Deserialize)] +pub struct ResetDatabaseQueryParams { + num_replicas: Option, + #[serde(default)] + host_type: HostType, +} + +pub async fn reset( + State(ctx): State, + Path(ResetDatabaseParams { name_or_identity }): Path, + Query(ResetDatabaseQueryParams { + num_replicas, + host_type, + }): Query, + Extension(auth): Extension, + program_bytes: Option, +) -> axum::response::Result> { + guard_host_type(host_type)?; + + let database_identity = name_or_identity.resolve(&ctx).await?; + let database = worker_ctx_find_database(&ctx, &database_identity) + .await? + .ok_or(NO_SUCH_DATABASE)?; + + ctx.authorize_action(auth.claims.identity, database.database_identity, Action::ResetDatabase) + .await + .map_err(Unauthorized::into_response)?; + + let num_replicas = num_replicas.map(validate_replication_factor).transpose()?.flatten(); + ctx.reset_database( + &auth.claims.identity, + DatabaseResetDef { + database_identity, + program_bytes, + num_replicas, + host_type: Some(host_type), + }, + ) + .await + .map_err(log_and_500)?; + + Ok(axum::Json(PublishResult::Success { + domain: name_or_identity.name().cloned(), + database_identity, + op: PublishOp::Updated, + })) +} + #[derive(Deserialize)] pub struct PublishDatabaseParams { name_or_identity: Option, @@ -530,7 +579,7 @@ pub struct PublishDatabaseQueryParams { parent: Option, } -pub async fn publish( +pub async fn publish( State(ctx): State, Path(PublishDatabaseParams { name_or_identity }): Path, Query(PublishDatabaseQueryParams { @@ -542,50 +591,77 @@ pub async fn publish( parent, }): Query, Extension(auth): Extension, - body: Bytes, + program_bytes: Bytes, ) -> axum::response::Result> { - // Feature gate V8 modules. - // The host must've been compiled with the `unstable` feature. - // TODO(v8): ungate this when V8 is ready to ship. - #[cfg(not(feature = "unstable"))] - if host_type == HostType::Js { - return Err(( - StatusCode::BAD_REQUEST, - "JS host type requires a host with unstable features", - ) - .into()); + // If `clear`, check that the database exists and delegate to `reset`. + // If it doesn't exist, ignore the `clear` parameter. + // TODO: Replace with actual redirect at the next possible version bump. + if clear { + let name_or_identity = name_or_identity + .as_ref() + .ok_or_else(|| bad_request("Clear database requires database name or identity".into()))?; + if let Ok(identity) = name_or_identity.try_resolve(&ctx).await.map_err(log_and_500)? { + if ctx.get_database_by_identity(&identity).map_err(log_and_500)?.is_some() { + return reset( + State(ctx), + Path(ResetDatabaseParams { + name_or_identity: name_or_identity.clone(), + }), + Query(ResetDatabaseQueryParams { + num_replicas, + host_type, + }), + Extension(auth), + Some(program_bytes), + ) + .await; + } + } } + guard_host_type(host_type)?; + let (database_identity, db_name) = get_or_create_identity_and_name(&ctx, &auth, name_or_identity.as_ref()).await?; + let maybe_parent_database_identity = match parent.as_ref() { + None => None, + Some(parent) => parent.resolve(&ctx).await.map(Some)?, + }; + + // Check that the replication factor looks somewhat sane. + let num_replicas = num_replicas.map(validate_replication_factor).transpose()?.flatten(); log::trace!("Publishing to the identity: {}", database_identity.to_hex()); // Check if the database already exists. - let exists = ctx - .get_database_by_identity(&database_identity) - .map_err(log_and_500)? - .is_some(); - // If not, check that the we caller is sufficiently authenticated. - if !exists { - allow_creation(&auth)?; - } - // If the `clear` flag was given, clear the database if it exists. - // NOTE: The `clear_database` method has to check authorization. - if clear && exists { - ctx.clear_database(&auth.claims.identity, &database_identity) - .await - .map_err(log_and_500)?; + let existing = ctx.get_database_by_identity(&database_identity).map_err(log_and_500)?; + match existing.as_ref() { + // If not, check that the we caller is sufficiently authenticated. + None => { + allow_creation(&auth)?; + if let Some(parent) = maybe_parent_database_identity { + ctx.authorize_action( + auth.claims.identity, + database_identity, + Action::CreateDatabase { parent: Some(parent) }, + ) + .await + .map_err(Unauthorized::into_response)?; + } + } + // If yes, authorize via ctx. + Some(database) => { + ctx.authorize_action(auth.claims.identity, database.database_identity, Action::UpdateDatabase) + .await + .map_err(Unauthorized::into_response)?; + } } + // Indicate in the response whether we created or updated the database. - let publish_op = if exists { PublishOp::Updated } else { PublishOp::Created }; - // Check that the replication factor looks somewhat sane. - let num_replicas = num_replicas - .map(|n| { - let n = u8::try_from(n).map_err(|_| bad_request(format!("Replication factor {n} out of bounds").into()))?; - Ok::<_, ErrorResponse>(NonZeroU8::new(n)) - }) - .transpose()? - .flatten(); + let publish_op = if existing.is_some() { + PublishOp::Updated + } else { + PublishOp::Created + }; // If a parent is given, resolve to an existing database. let parent = if let Some(name_or_identity) = parent { let identity = name_or_identity @@ -603,7 +679,7 @@ pub async fn publish( &auth.claims.identity, DatabaseDef { database_identity, - program_bytes: body.into(), + program_bytes, num_replicas, host_type, parent, @@ -716,6 +792,25 @@ fn schema_migration_policy( } } +fn guard_host_type(host_type: HostType) -> Result<(), ErrorResponse> { + // Feature gate V8 modules. + // The host must've been compiled with the `unstable` feature. + // TODO(v8): ungate this when V8 is ready to ship. + #[cfg(not(feature = "unstable"))] + if host_type == HostType::Js { + return Err(bad_request( + "JS host type requires a host with unstable features".into(), + )); + } + + Ok(()) +} + +fn validate_replication_factor(n: usize) -> Result, ErrorResponse> { + let n = u8::try_from(n).map_err(|_| bad_request(format!("Replication factor {n} out of bounds").into()))?; + Ok(NonZeroU8::new(n)) +} + fn bad_request(message: Cow<'static, str>) -> ErrorResponse { (StatusCode::BAD_REQUEST, message).into() } @@ -731,12 +826,12 @@ pub struct PrePublishQueryParams { style: PrettyPrintStyle, } -pub async fn pre_publish( +pub async fn pre_publish( State(ctx): State, Path(PrePublishParams { name_or_identity }): Path, Query(PrePublishQueryParams { style }): Query, Extension(auth): Extension, - body: Bytes, + program_bytes: Bytes, ) -> axum::response::Result> { // User should not be able to print migration plans for a database that they do not own let database_identity = resolve_and_authenticate(&ctx, &name_or_identity, &auth).await?; @@ -749,7 +844,7 @@ pub async fn pre_publish( .migrate_plan( DatabaseDef { database_identity, - program_bytes: body.into(), + program_bytes, num_replicas: None, host_type: HostType::Wasm, parent: None, @@ -790,28 +885,19 @@ pub async fn pre_publish( /// Resolves the [`NameOrIdentity`] to a database identity and checks if the /// `auth` identity owns the database. -async fn resolve_and_authenticate( +async fn resolve_and_authenticate( ctx: &S, name_or_identity: &NameOrIdentity, auth: &SpacetimeAuth, ) -> axum::response::Result { let database_identity = name_or_identity.resolve(ctx).await?; - let database = worker_ctx_find_database(ctx, &database_identity) .await? .ok_or(NO_SUCH_DATABASE)?; - if database.owner_identity != auth.claims.identity { - return Err(( - StatusCode::UNAUTHORIZED, - format!( - "Identity does not own database, expected: {} got: {}", - database.owner_identity.to_hex(), - auth.claims.identity.to_hex() - ), - ) - .into()); - } + ctx.authorize_action(auth.claims.identity, database.database_identity, Action::UpdateDatabase) + .await + .map_err(Unauthorized::into_response)?; Ok(database_identity) } @@ -821,13 +907,19 @@ pub struct DeleteDatabaseParams { pub name_or_identity: NameOrIdentity, } -pub async fn delete_database( +pub async fn delete_database( State(ctx): State, Path(DeleteDatabaseParams { name_or_identity }): Path, Extension(auth): Extension, ) -> axum::response::Result { let database_identity = name_or_identity.resolve(&ctx).await?; + let Some(_database) = worker_ctx_find_database(&ctx, &database_identity).await? else { + return Ok(()); + }; + ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase) + .await + .map_err(Unauthorized::into_response)?; ctx.delete_database(&auth.claims.identity, &database_identity) .await .map_err(log_and_500)?; @@ -870,7 +962,7 @@ pub struct SetNamesParams { name_or_identity: NameOrIdentity, } -pub async fn set_names( +pub async fn set_names( State(ctx): State, Path(SetNamesParams { name_or_identity }): Path, Extension(auth): Extension, @@ -893,14 +985,18 @@ pub async fn set_names( )); }; - if database.owner_identity != auth.claims.identity { - return Ok(( - StatusCode::UNAUTHORIZED, - axum::Json(name::SetDomainsResult::NotYourDatabase { - database: database.database_identity, - }), - )); - } + ctx.authorize_action(auth.claims.identity, database.database_identity, Action::RenameDatabase) + .await + .map_err(|e| match e { + Unauthorized::Unauthorized { .. } => ( + StatusCode::UNAUTHORIZED, + axum::Json(name::SetDomainsResult::NotYourDatabase { + database: database.database_identity, + }), + ) + .into(), + Unauthorized::InternalError(e) => log_and_500(e), + })?; for name in &validated_names { if ctx.lookup_identity(name.as_str()).unwrap().is_some() { @@ -987,13 +1083,15 @@ pub struct DatabaseRoutes { pub sql_post: MethodRouter, /// POST: /database/:name_or_identity/pre-publish pub pre_publish: MethodRouter, + /// PUT: /database/:name_or_identity/reset + pub db_reset: MethodRouter, /// GET: /database/: name_or_identity/unstable/timestamp pub timestamp_get: MethodRouter, } impl Default for DatabaseRoutes where - S: NodeDelegate + ControlStateDelegate + HasWebSocketOptions + Clone + 'static, + S: NodeDelegate + ControlStateDelegate + HasWebSocketOptions + Authorization + Clone + 'static, { fn default() -> Self { use axum::routing::{delete, get, post, put}; @@ -1012,6 +1110,7 @@ where logs_get: get(logs::), sql_post: post(sql::), pre_publish: post(pre_publish::), + db_reset: put(reset::), timestamp_get: get(get_timestamp::), } } @@ -1019,7 +1118,7 @@ where impl DatabaseRoutes where - S: NodeDelegate + ControlStateDelegate + Clone + 'static, + S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static, { pub fn into_router(self, ctx: S) -> axum::Router { let db_router = axum::Router::::new() @@ -1036,7 +1135,8 @@ where .route("/logs", self.logs_get) .route("/sql", self.sql_post) .route("/unstable/timestamp", self.timestamp_get) - .route("/pre_publish", self.pre_publish); + .route("/pre_publish", self.pre_publish) + .route("/reset", self.db_reset); axum::Router::new() .route("/", self.root_post) diff --git a/crates/client-api/src/routes/mod.rs b/crates/client-api/src/routes/mod.rs index f0930eefb4c..940f624e13a 100644 --- a/crates/client-api/src/routes/mod.rs +++ b/crates/client-api/src/routes/mod.rs @@ -2,7 +2,7 @@ use database::DatabaseRoutes; use http::header; use tower_http::cors; -use crate::{ControlStateDelegate, NodeDelegate}; +use crate::{Authorization, ControlStateDelegate, NodeDelegate}; pub mod database; pub mod energy; @@ -20,7 +20,7 @@ pub async fn ping(_auth: crate::auth::SpacetimeAuthHeader) {} #[allow(clippy::let_and_return)] pub fn router(ctx: &S, database_routes: DatabaseRoutes, extra: axum::Router) -> axum::Router where - S: NodeDelegate + ControlStateDelegate + Clone + 'static, + S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static, { use axum::routing::get; let router = axum::Router::new() diff --git a/crates/client-api/src/util.rs b/crates/client-api/src/util.rs index c38bf33c0ae..509b891e483 100644 --- a/crates/client-api/src/util.rs +++ b/crates/client-api/src/util.rs @@ -89,7 +89,7 @@ impl NameOrIdentity { /// Otherwise, if `self` is a [`NameOrIdentity::Name`], the [`Identity`] is /// looked up by that name in the SpacetimeDB DNS and returned. /// - /// Errors are returned if [`NameOrIdentity::Name`] the DNS lookup fails. + /// Errors are returned if the DNS lookup fails. /// /// An `Ok` result is itself a [`Result`], which is `Err(DatabaseName)` if the /// given [`NameOrIdentity::Name`] is not registered in the SpacetimeDB DNS, @@ -111,7 +111,7 @@ impl NameOrIdentity { self.try_resolve(ctx) .await .map_err(log_and_500)? - .map_err(|_| StatusCode::NOT_FOUND.into()) + .map_err(|name| (StatusCode::NOT_FOUND, format!("Could not resolve database `{name}`")).into()) } } diff --git a/crates/core/src/sql/ast.rs b/crates/core/src/sql/ast.rs index 898a30c7279..8b76d80a67a 100644 --- a/crates/core/src/sql/ast.rs +++ b/crates/core/src/sql/ast.rs @@ -6,7 +6,6 @@ use spacetimedb_datastore::locking_tx_datastore::state_view::StateView; use spacetimedb_datastore::system_tables::{StRowLevelSecurityFields, ST_ROW_LEVEL_SECURITY_ID}; use spacetimedb_expr::check::SchemaView; use spacetimedb_expr::statement::compile_sql_stmt; -use spacetimedb_lib::db::auth::StAccess; use spacetimedb_lib::identity::AuthCtx; use spacetimedb_primitives::{ColId, TableId}; use spacetimedb_sats::{AlgebraicType, AlgebraicValue}; @@ -492,22 +491,20 @@ impl Deref for SchemaViewer<'_, T> { impl SchemaView for SchemaViewer<'_, T> { fn table_id(&self, name: &str) -> Option { - let AuthCtx { owner, caller } = self.auth; // Get the schema from the in-memory state instead of fetching from the database for speed self.tx .table_id_from_name(name) .ok() .flatten() .and_then(|table_id| self.schema_for_table(table_id)) - .filter(|schema| schema.table_access == StAccess::Public || caller == owner) + .filter(|schema| self.auth.has_read_access(schema.table_access)) .map(|schema| schema.table_id) } fn schema_for_table(&self, table_id: TableId) -> Option> { - let AuthCtx { owner, caller } = self.auth; self.tx .get_schema(table_id) - .filter(|schema| schema.table_access == StAccess::Public || caller == owner) + .filter(|schema| self.auth.has_read_access(schema.table_access)) .cloned() } diff --git a/crates/core/src/sql/execute.rs b/crates/core/src/sql/execute.rs index 089148cdd2e..60a400a36af 100644 --- a/crates/core/src/sql/execute.rs +++ b/crates/core/src/sql/execute.rs @@ -122,7 +122,7 @@ pub fn execute_sql( let mut tx = db.begin_mut_tx(IsolationLevel::Serializable, Workload::Sql); let mut updates = Vec::with_capacity(ast.len()); let res = execute( - &mut DbProgram::new(db, &mut (&mut tx).into(), auth), + &mut DbProgram::new(db, &mut (&mut tx).into(), auth.clone()), ast, sql, &mut updates, @@ -130,7 +130,7 @@ pub fn execute_sql( if res.is_ok() && !updates.is_empty() { let event = ModuleEvent { timestamp: Timestamp::now(), - caller_identity: auth.caller, + caller_identity: auth.caller(), caller_connection_id: None, function_call: ModuleFunctionCall { reducer: String::new(), @@ -249,7 +249,7 @@ pub fn run( } Statement::DML(stmt) => { // An extra layer of auth is required for DML - if auth.caller != auth.owner { + if !auth.has_write_access() { return Err(anyhow!("Only owners are authorized to run SQL DML statements").into()); } @@ -287,7 +287,7 @@ pub fn run( None, ModuleEvent { timestamp: Timestamp::now(), - caller_identity: auth.caller, + caller_identity: auth.caller(), caller_connection_id: None, function_call: ModuleFunctionCall { reducer: String::new(), diff --git a/crates/core/src/subscription/execution_unit.rs b/crates/core/src/subscription/execution_unit.rs index 75eb0a0442c..794495d38c7 100644 --- a/crates/core/src/subscription/execution_unit.rs +++ b/crates/core/src/subscription/execution_unit.rs @@ -10,6 +10,7 @@ use crate::util::slow::SlowQueryLogger; use crate::vm::{build_query, TxMode}; use spacetimedb_client_api_messages::websocket::{Compression, QueryUpdate, RowListLen as _, SingleQueryUpdate}; use spacetimedb_datastore::locking_tx_datastore::TxId; +use spacetimedb_lib::identity::AuthCtx; use spacetimedb_lib::Identity; use spacetimedb_primitives::TableId; use spacetimedb_sats::{u256, ProductValue}; @@ -335,7 +336,7 @@ impl ExecutionUnit { } impl AuthAccess for ExecutionUnit { - fn check_auth(&self, owner: Identity, caller: Identity) -> Result<(), AuthError> { - self.eval_plan.check_auth(owner, caller) + fn check_auth(&self, auth: &AuthCtx) -> Result<(), AuthError> { + self.eval_plan.check_auth(auth) } } diff --git a/crates/core/src/subscription/module_subscription_actor.rs b/crates/core/src/subscription/module_subscription_actor.rs index 41eb62e60ba..4b75c3a00eb 100644 --- a/crates/core/src/subscription/module_subscription_actor.rs +++ b/crates/core/src/subscription/module_subscription_actor.rs @@ -330,8 +330,8 @@ impl ModuleSubscriptions { let sql = request.query; let auth = AuthCtx::new(self.owner_identity, sender.id.identity); - let hash = QueryHash::from_string(&sql, auth.caller, false); - let hash_with_param = QueryHash::from_string(&sql, auth.caller, true); + let hash = QueryHash::from_string(&sql, auth.caller(), false); + let hash_with_param = QueryHash::from_string(&sql, auth.caller(), true); let (tx, tx_offset) = self.begin_tx(Workload::Subscribe); diff --git a/crates/core/src/subscription/query.rs b/crates/core/src/subscription/query.rs index 02036f4b299..6235ba413b0 100644 --- a/crates/core/src/subscription/query.rs +++ b/crates/core/src/subscription/query.rs @@ -87,7 +87,7 @@ pub fn compile_read_only_query(auth: &AuthCtx, tx: &Tx, input: &str) -> Result

> for ExecutionSet { } impl AuthAccess for ExecutionSet { - fn check_auth(&self, owner: Identity, caller: Identity) -> Result<(), AuthError> { - self.exec_units.iter().try_for_each(|eu| eu.check_auth(owner, caller)) + fn check_auth(&self, auth: &AuthCtx) -> Result<(), AuthError> { + self.exec_units.iter().try_for_each(|eu| eu.check_auth(auth)) } } @@ -616,7 +615,7 @@ pub(crate) fn get_all(relational_db: &RelationalDB, tx: &Tx, auth: &AuthCtx) -> .get_all_tables(tx)? .iter() .map(Deref::deref) - .filter(|t| t.table_type == StTableType::User && (auth.is_owner() || t.table_access == StAccess::Public)) + .filter(|t| t.table_type == StTableType::User && auth.has_read_access(t.table_access)) .map(|schema| { let sql = format!("SELECT * FROM {}", schema.table_name); let tx = SchemaViewer::new(tx, auth); @@ -625,12 +624,12 @@ pub(crate) fn get_all(relational_db: &RelationalDB, tx: &Tx, auth: &AuthCtx) -> plans, QueryHash::from_string( &sql, - auth.caller, + auth.caller(), // Note that when generating hashes for queries from owners, // we always treat them as if they were parameterized by :sender. // This is because RLS is not applicable to owners. // Hence owner hashes must never overlap with client hashes. - auth.is_owner() || has_param, + auth.bypass_rls() || has_param, ), sql, ) @@ -652,7 +651,7 @@ pub(crate) fn legacy_get_all( .get_all_tables(tx)? .iter() .map(Deref::deref) - .filter(|t| t.table_type == StTableType::User && (auth.is_owner() || t.table_access == StAccess::Public)) + .filter(|t| t.table_type == StTableType::User && auth.has_read_access(t.table_access)) .map(|src| SupportedQuery { kind: query::Supported::Select, expr: QueryExpr::new(src), diff --git a/crates/core/src/vm.rs b/crates/core/src/vm.rs index 8fa228e41e4..9a21340e811 100644 --- a/crates/core/src/vm.rs +++ b/crates/core/src/vm.rs @@ -466,7 +466,7 @@ pub fn check_row_limit( row_est: impl Fn(&Query, &TxId) -> u64, auth: &AuthCtx, ) -> Result<(), DBError> { - if auth.caller != auth.owner { + if !auth.can_exceed_row_limit() { if let Some(limit) = db.row_limit(tx)? { let mut estimate: u64 = 0; for query in queries { @@ -627,7 +627,7 @@ impl<'db, 'tx> DbProgram<'db, 'tx> { impl ProgramVm for DbProgram<'_, '_> { // Safety: For DbProgram with tx = TxMode::Tx variant, all queries must match to CrudCode::Query and no other branch. fn eval_query(&mut self, query: CrudExpr, sources: Sources<'_, N>) -> Result { - query.check_auth(self.auth.owner, self.auth.caller)?; + query.check_auth(&self.auth)?; match query { CrudExpr::Query(query) => self._eval_query(&query, sources), diff --git a/crates/expr/src/check.rs b/crates/expr/src/check.rs index 14aaa34a9ed..8976e22a210 100644 --- a/crates/expr/src/check.rs +++ b/crates/expr/src/check.rs @@ -160,7 +160,7 @@ impl TypeChecker for SubChecker { pub fn parse_and_type_sub(sql: &str, tx: &impl SchemaView, auth: &AuthCtx) -> TypingResult<(ProjectName, bool)> { let ast = parse_subscription(sql)?; let has_param = ast.has_parameter(); - let ast = ast.resolve_sender(auth.caller); + let ast = ast.resolve_sender(auth.caller()); expect_table_type(SubChecker::type_ast(ast, tx)?).map(|plan| (plan, has_param)) } diff --git a/crates/expr/src/rls.rs b/crates/expr/src/rls.rs index 89bdf7149f8..2c3afaed8ff 100644 --- a/crates/expr/src/rls.rs +++ b/crates/expr/src/rls.rs @@ -18,7 +18,7 @@ pub fn resolve_views_for_sub( has_param: &mut bool, ) -> anyhow::Result> { // RLS does not apply to the database owner - if auth.is_owner() { + if auth.bypass_rls() { return Ok(vec![expr]); } @@ -56,7 +56,7 @@ pub fn resolve_views_for_sub( /// Mainly a wrapper around [resolve_views_for_expr]. pub fn resolve_views_for_sql(tx: &impl SchemaView, expr: ProjectList, auth: &AuthCtx) -> anyhow::Result { // RLS does not apply to the database owner - if auth.is_owner() { + if auth.bypass_rls() { return Ok(expr); } // The subscription language is a subset of the sql language. diff --git a/crates/expr/src/statement.rs b/crates/expr/src/statement.rs index 50e9bdb4c22..45716fa18e2 100644 --- a/crates/expr/src/statement.rs +++ b/crates/expr/src/statement.rs @@ -428,7 +428,7 @@ impl TypeChecker for SqlChecker { } pub fn parse_and_type_sql(sql: &str, tx: &impl SchemaView, auth: &AuthCtx) -> TypingResult { - match parse_sql(sql)?.resolve_sender(auth.caller) { + match parse_sql(sql)?.resolve_sender(auth.caller()) { SqlAst::Select(ast) => Ok(Statement::Select(SqlChecker::type_ast(ast, tx)?)), SqlAst::Insert(insert) => Ok(Statement::DML(DML::Insert(type_insert(insert, tx)?))), SqlAst::Delete(delete) => Ok(Statement::DML(DML::Delete(type_delete(delete, tx)?))), diff --git a/crates/lib/src/identity.rs b/crates/lib/src/identity.rs index 8420e5e6245..ba8a8e8d7bb 100644 --- a/crates/lib/src/identity.rs +++ b/crates/lib/src/identity.rs @@ -1,34 +1,87 @@ +use crate::db::auth::StAccess; use crate::from_hex_pad; use blake3; use core::mem; use spacetimedb_bindings_macro::{Deserialize, Serialize}; use spacetimedb_sats::hex::HexString; use spacetimedb_sats::{impl_st, u256, AlgebraicType, AlgebraicValue}; +use std::sync::Arc; use std::{fmt, str::FromStr}; pub type RequestId = u32; -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct AuthCtx { - pub owner: Identity, - pub caller: Identity, +pub enum SqlPermission { + Read(StAccess), + Write, + ExceedRowLimit, + BypassRLS, +} + +pub trait ExternalAuthCtx { + fn has_sql_permission(&self, p: SqlPermission) -> bool; +} + +impl bool> ExternalAuthCtx for T { + fn has_sql_permission(&self, p: SqlPermission) -> bool { + self(p) + } +} + +/// Authorization for SQL operations (queries, DML, subscription queries). +#[derive(Clone)] +pub enum AuthCtx { + Simple { + owner: Identity, + caller: Identity, + }, + External { + caller: Identity, + permissions: Arc, + }, } impl AuthCtx { pub fn new(owner: Identity, caller: Identity) -> Self { - Self { owner, caller } + Self::Simple { owner, caller } } + /// For when the owner == caller pub fn for_current(owner: Identity) -> Self { - Self { owner, caller: owner } + Self::Simple { owner, caller: owner } + } + + pub fn has_permission(&self, p: SqlPermission) -> bool { + match self { + Self::Simple { owner, caller } => owner == caller, + Self::External { permissions, .. } => permissions.has_sql_permission(p), + } } - /// Does `owner == caller` - pub fn is_owner(&self) -> bool { - self.owner == self.caller + + pub fn has_read_access(&self, table_access: StAccess) -> bool { + self.has_permission(SqlPermission::Read(table_access)) + } + + pub fn has_write_access(&self) -> bool { + self.has_permission(SqlPermission::Write) } + + pub fn can_exceed_row_limit(&self) -> bool { + self.has_permission(SqlPermission::ExceedRowLimit) + } + + pub fn bypass_rls(&self) -> bool { + self.has_permission(SqlPermission::BypassRLS) + } + + pub fn caller(&self) -> Identity { + match self { + Self::Simple { caller, .. } | Self::External { caller, .. } => *caller, + } + } + /// WARNING: Use this only for simple test were the `auth` don't matter pub fn for_testing() -> Self { - AuthCtx { + Self::Simple { owner: Identity::__dummy(), caller: Identity::__dummy(), } diff --git a/crates/pg/src/pg_server.rs b/crates/pg/src/pg_server.rs index 39b0fcacacd..03de3d24a9d 100644 --- a/crates/pg/src/pg_server.rs +++ b/crates/pg/src/pg_server.rs @@ -25,7 +25,7 @@ use pgwire::tokio::process_socket; use spacetimedb_client_api::auth::validate_token; use spacetimedb_client_api::routes::database; use spacetimedb_client_api::routes::database::{SqlParams, SqlQueryParams}; -use spacetimedb_client_api::{ControlStateReadAccess, ControlStateWriteAccess, NodeDelegate}; +use spacetimedb_client_api::{Authorization, ControlStateReadAccess, ControlStateWriteAccess, NodeDelegate}; use spacetimedb_client_api_messages::http::SqlStmtResult; use spacetimedb_client_api_messages::name::DatabaseName; use spacetimedb_lib::sats::satn::{PsqlClient, TypedSerializer}; @@ -149,7 +149,10 @@ struct PgSpacetimeDB { parameter_provider: DefaultServerParameterProvider, } -impl PgSpacetimeDB { +impl PgSpacetimeDB +where + T: ControlStateReadAccess + ControlStateWriteAccess + NodeDelegate + Authorization + Clone, +{ async fn exe_sql<'a>(&self, query: String) -> PgWireResult>> { let params = self.cached.lock().await.clone().unwrap(); let db = SqlParams { @@ -298,8 +301,9 @@ impl SimpleQueryHandler - for PgSpacetimeDB +impl SimpleQueryHandler for PgSpacetimeDB +where + T: Sync + Send + ControlStateReadAccess + ControlStateWriteAccess + NodeDelegate + Authorization + Clone, { async fn do_query<'a, C>(&self, _client: &mut C, query: &str) -> PgWireResult>> where @@ -330,8 +334,9 @@ impl PgSpacetimeDBFactory { } } -impl PgWireServerHandlers - for PgSpacetimeDBFactory +impl PgWireServerHandlers for PgSpacetimeDBFactory +where + T: Sync + Send + ControlStateReadAccess + ControlStateWriteAccess + NodeDelegate + Authorization + Clone, { fn simple_query_handler(&self) -> Arc { self.handler.clone() @@ -344,11 +349,10 @@ impl( - shutdown: Arc, - ctx: T, - tcp: TcpListener, -) { +pub async fn start_pg(shutdown: Arc, ctx: T, tcp: TcpListener) +where + T: ControlStateReadAccess + ControlStateWriteAccess + NodeDelegate + Authorization + Clone + 'static, +{ let factory = Arc::new(PgSpacetimeDBFactory::new(ctx)); log::debug!( diff --git a/crates/schema/src/def/error.rs b/crates/schema/src/def/error.rs index 83bae995fc1..7acdfd3a937 100644 --- a/crates/schema/src/def/error.rs +++ b/crates/schema/src/def/error.rs @@ -77,8 +77,8 @@ pub enum AuthError { IndexPrivate { named: String }, #[error("Sequence `{named}` is private")] SequencePrivate { named: String }, - #[error("Only the database owner can perform the requested operation")] - OwnerRequired, + #[error("Insufficient privileges to perform the requested operation")] + InsuffientPrivileges, #[error("Constraint `{named}` is private")] ConstraintPrivate { named: String }, } diff --git a/crates/standalone/src/control_db.rs b/crates/standalone/src/control_db.rs index 00152ef3d46..6128cf8614e 100644 --- a/crates/standalone/src/control_db.rs +++ b/crates/standalone/src/control_db.rs @@ -38,6 +38,8 @@ pub enum Error { RecordAlreadyExists(DomainName), #[error("database with identity {0} already exists")] DatabaseAlreadyExists(Identity), + #[error("database with identity {0} does not exist")] + DatabaseNotFound(Identity), #[error("failed to register {0} domain")] DomainRegistrationFailure(DomainName), #[error("failed to decode data")] @@ -377,6 +379,21 @@ impl ControlDb { Ok(id) } + pub(crate) fn update_database(&self, database: Database) -> Result<()> { + let Some(stored_database) = self.get_database_by_identity(&database.database_identity)? else { + return Err(Error::DatabaseNotFound(database.database_identity)); + }; + + let tree = self.db.open_tree("database_by_identity")?; + let buf = sled::IVec::from(compat::Database::from(database).to_vec()?); + tree.insert(stored_database.database_identity.to_be_byte_array(), buf.clone())?; + + let tree = self.db.open_tree("database")?; + tree.insert(stored_database.id.to_be_bytes(), buf)?; + + Ok(()) + } + pub fn delete_database(&self, id: u64) -> Result> { let tree = self.db.open_tree("database")?; let tree_by_identity = self.db.open_tree("database_by_identity")?; @@ -430,7 +447,7 @@ impl ControlDb { // if !tree.contains_key(database_id.to_be_bytes())? { // return Err(anyhow::anyhow!("No such database.")); // } - // + //1073741824 let replicas = self .get_replicas()? .iter() diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index c70a75d1964..59fa9b96f5d 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -5,7 +5,7 @@ pub mod version; use crate::control_db::ControlDb; use crate::subcommands::{extract_schema, start}; -use anyhow::{ensure, Context as _, Ok}; +use anyhow::Context as _; use async_trait::async_trait; use clap::{ArgMatches, Command}; use spacetimedb::client::ClientActorIndex; @@ -14,13 +14,13 @@ use spacetimedb::db; use spacetimedb::db::persistence::LocalPersistenceProvider; use spacetimedb::energy::{EnergyBalance, EnergyQuanta, NullEnergyMonitor}; use spacetimedb::host::{DiskStorage, HostController, MigratePlanResult, UpdateDatabaseResult}; -use spacetimedb::identity::Identity; +use spacetimedb::identity::{AuthCtx, Identity}; use spacetimedb::messages::control_db::{Database, Node, Replica}; use spacetimedb::util::jobs::JobCores; use spacetimedb::worker_metrics::WORKER_METRICS; use spacetimedb_client_api::auth::{self, LOCALHOST}; use spacetimedb_client_api::routes::subscribe::{HasWebSocketOptions, WebSocketOptions}; -use spacetimedb_client_api::{Host, NodeDelegate}; +use spacetimedb_client_api::{ControlStateReadAccess, DatabaseResetDef, Host, NodeDelegate}; use spacetimedb_client_api_messages::name::{DomainName, InsertDomainResult, RegisterTldResult, SetDomainsResult, Tld}; use spacetimedb_datastore::db_metrics::data_size::DATA_SIZE_METRICS; use spacetimedb_datastore::db_metrics::DB_METRICS; @@ -258,13 +258,6 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv { // The database already exists, so we'll try to update it. // If that fails, we'll keep the old one. Some(database) => { - ensure!( - &database.owner_identity == publisher, - "Permission denied: `{}` does not own database `{}`", - publisher, - spec.database_identity.to_abbreviated_hex() - ); - let database_id = database.id; let database_identity = database.database_identity; @@ -273,7 +266,7 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv { .await? .ok_or_else(|| anyhow::anyhow!("No leader for database"))?; let update_result = leader - .update(database, spec.host_type, spec.program_bytes.into(), policy) + .update(database, spec.host_type, spec.program_bytes.to_vec().into(), policy) .await?; if update_result.was_successful() { let replicas = self.control_db.get_replicas_by_database(database_id)?; @@ -337,7 +330,13 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv { .await? .ok_or_else(|| anyhow::anyhow!("No leader for database"))?; self.host_controller - .migrate_plan(db, spec.host_type, host.replica_id, spec.program_bytes.into(), style) + .migrate_plan( + db, + spec.host_type, + host.replica_id, + spec.program_bytes.to_vec().into(), + style, + ) .await } None => anyhow::bail!( @@ -347,19 +346,10 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv { } } - async fn delete_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()> { + async fn delete_database(&self, _caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()> { let Some(database) = self.control_db.get_database_by_identity(database_identity)? else { return Ok(()); }; - anyhow::ensure!( - &database.owner_identity == caller_identity, - // TODO: `PermissionDenied` should be a variant of `Error`, - // so we can match on it and return better error responses - // from HTTP endpoints. - "Permission denied: `{caller_identity}` does not own database `{}`", - database_identity.to_abbreviated_hex() - ); - self.control_db.delete_database(database.id)?; for instance in self.control_db.get_replicas_by_database(database.id)? { @@ -369,24 +359,38 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv { Ok(()) } - async fn clear_database(&self, caller_identity: &Identity, database_identity: &Identity) -> anyhow::Result<()> { - let database = self + async fn reset_database(&self, _caller_identity: &Identity, spec: DatabaseResetDef) -> anyhow::Result<()> { + let mut database = self .control_db - .get_database_by_identity(database_identity)? - .with_context(|| format!("Database `{database_identity}` does not exist"))?; + .get_database_by_identity(&spec.database_identity)? + .with_context(|| format!("Database `{}` does not exist", spec.database_identity))?; + let database_id = database.id; + + if let Some(program) = spec.program_bytes { + let program_bytes = &program[..]; + let program = Program::from_bytes(program_bytes); + let _hash_for_assert = program.hash; + + database.initial_program = program.hash; + if let Some(host_type) = spec.host_type { + database.host_type = host_type; + } - anyhow::ensure!( - &database.owner_identity == caller_identity, - "Permission denied: `{caller_identity}` does not own database `{database_identity}`" - ); + self.host_controller + .check_module_validity(database.clone(), program) + .await?; + let _stored_hash_for_assert = self.program_store.put(program_bytes).await?; + debug_assert_eq!(_hash_for_assert, _stored_hash_for_assert); - let mut num_replicas = 0; - for instance in self.control_db.get_replicas_by_database(database.id)? { - self.delete_replica(instance.id).await?; - num_replicas -= 1; + self.control_db.update_database(database)?; } - self.schedule_replicas(database.id, num_replicas).await?; + for instance in self.control_db.get_replicas_by_database(database_id)? { + self.delete_replica(instance.id).await?; + } + // Standalone only support a single replica. + let num_replicas = 1; + self.schedule_replicas(database_id, num_replicas).await?; Ok(()) } @@ -434,6 +438,43 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv { } } +impl spacetimedb_client_api::Authorization for StandaloneEnv { + async fn authorize_action( + &self, + subject: Identity, + database: Identity, + action: spacetimedb_client_api::Action, + ) -> Result<(), spacetimedb_client_api::Unauthorized> { + let database = self + .get_database_by_identity(&database)? + .with_context(|| format!("database {database} not found"))?; + if subject == database.owner_identity { + return Ok(()); + } + + Err(spacetimedb_client_api::Unauthorized::Unauthorized { + subject, + action, + source: None, + }) + } + + async fn authorize_sql( + &self, + subject: Identity, + database: Identity, + ) -> Result { + let database = self + .get_database_by_identity(&database)? + .with_context(|| format!("database {database} not found"))?; + + Ok(AuthCtx::Simple { + owner: database.owner_identity, + caller: subject, + }) + } +} + impl StandaloneEnv { async fn insert_replica(&self, replica: Replica) -> Result<(), anyhow::Error> { let mut new_replica = replica.clone(); diff --git a/crates/vm/src/expr.rs b/crates/vm/src/expr.rs index d9a70d47548..1583930d30c 100644 --- a/crates/vm/src/expr.rs +++ b/crates/vm/src/expr.rs @@ -8,7 +8,7 @@ use itertools::Itertools; use smallvec::SmallVec; use spacetimedb_data_structures::map::{HashSet, IntMap}; use spacetimedb_lib::db::auth::{StAccess, StTableType}; -use spacetimedb_lib::Identity; +use spacetimedb_lib::identity::AuthCtx; use spacetimedb_primitives::*; use spacetimedb_sats::satn::Satn; use spacetimedb_sats::{AlgebraicType, AlgebraicValue, ProductValue}; @@ -25,7 +25,7 @@ use std::{fmt, iter, mem}; /// Trait for checking if the `caller` have access to `Self` pub trait AuthAccess { - fn check_auth(&self, owner: Identity, caller: Identity) -> Result<(), AuthError>; + fn check_auth(&self, auth: &AuthCtx) -> Result<(), AuthError>; } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, From)] @@ -1958,12 +1958,8 @@ impl QueryExpr { } impl AuthAccess for Query { - fn check_auth(&self, owner: Identity, caller: Identity) -> Result<(), AuthError> { - if owner == caller { - return Ok(()); - } - - self.walk_sources(&mut |s| s.check_auth(owner, caller)) + fn check_auth(&self, auth: &AuthCtx) -> Result<(), AuthError> { + self.walk_sources(&mut |s| s.check_auth(auth)) } } @@ -2017,8 +2013,8 @@ impl fmt::Display for Query { } impl AuthAccess for SourceExpr { - fn check_auth(&self, owner: Identity, caller: Identity) -> Result<(), AuthError> { - if owner == caller || self.table_access() == StAccess::Public { + fn check_auth(&self, auth: &AuthCtx) -> Result<(), AuthError> { + if auth.has_read_access(self.table_access()) { return Ok(()); } @@ -2029,26 +2025,24 @@ impl AuthAccess for SourceExpr { } impl AuthAccess for QueryExpr { - fn check_auth(&self, owner: Identity, caller: Identity) -> Result<(), AuthError> { - if owner == caller { - return Ok(()); - } - self.walk_sources(&mut |s| s.check_auth(owner, caller)) + fn check_auth(&self, auth: &AuthCtx) -> Result<(), AuthError> { + self.walk_sources(&mut |s| s.check_auth(auth)) } } impl AuthAccess for CrudExpr { - fn check_auth(&self, owner: Identity, caller: Identity) -> Result<(), AuthError> { - if owner == caller { - return Ok(()); - } + fn check_auth(&self, auth: &AuthCtx) -> Result<(), AuthError> { // Anyone may query, so as long as the tables involved are public. if let CrudExpr::Query(q) = self { - return q.check_auth(owner, caller); + return q.check_auth(auth); } // Mutating operations require `owner == caller`. - Err(AuthError::OwnerRequired) + if !auth.has_write_access() { + return Err(AuthError::InsuffientPrivileges); + } + + Ok(()) } } @@ -2201,16 +2195,19 @@ mod tests { } fn assert_owner_private(auth: &T) { - assert!(auth.check_auth(ALICE, ALICE).is_ok()); + assert!(auth.check_auth(&AuthCtx::new(ALICE, ALICE)).is_ok()); assert!(matches!( - auth.check_auth(ALICE, BOB), + auth.check_auth(&AuthCtx::new(ALICE, BOB)), Err(AuthError::TablePrivate { .. }) )); } fn assert_owner_required(auth: T) { - assert!(auth.check_auth(ALICE, ALICE).is_ok()); - assert!(matches!(auth.check_auth(ALICE, BOB), Err(AuthError::OwnerRequired))); + assert!(auth.check_auth(&AuthCtx::new(ALICE, ALICE)).is_ok()); + assert!(matches!( + auth.check_auth(&AuthCtx::new(ALICE, BOB)), + Err(AuthError::InsuffientPrivileges) + )); } fn mem_table(id: TableId, name: &str, fields: &[(u16, AlgebraicType, bool)]) -> SourceExpr { diff --git a/smoketests/tests/teams.py b/smoketests/tests/teams.py index 2734b147cc5..6cabaa9dfc8 100644 --- a/smoketests/tests/teams.py +++ b/smoketests/tests/teams.py @@ -1,3 +1,6 @@ +import json +import toml + from .. import Smoketest, parse_sql_result, random_string class CreateChildDatabase(Smoketest): @@ -5,7 +8,8 @@ class CreateChildDatabase(Smoketest): def test_create_child_database(self): """ - Test that the owner can add a child database + Test that the owner can add a child database, + and that deleting the parent also deletes the child. """ parent_name = random_string() @@ -31,3 +35,294 @@ def query_controldb(self, parent, child): f"select * from database where database_identity = 0x{parent} or database_identity = 0x{child}" ) return parse_sql_result(str(res)) + +class PermissionsTest(Smoketest): + AUTOPUBLISH = False + + def create_identity(self): + """ + Obtain a fresh identity and token from the server. + Doesn't alter the config.toml for this test instance. + """ + resp = self.api_call("POST", "/v1/identity") + return json.loads(resp) + + def create_collaborators(self, database): + """ + Create collaborators for the current database, one for each role. + """ + collaborators = {} + roles = ["Owner", "Admin", "Developer", "Viewer"] + for role in roles: + identity_and_token = self.create_identity() + self.call_controldb_reducer( + "upsert_collaborator", + {"Name": database}, + [f"0x{identity_and_token['identity']}"], + {role: {}} + ) + collaborators[role] = identity_and_token + return collaborators + + + def call_controldb_reducer(self, reducer, *args): + """ + Call a controldb reducer. + """ + self.spacetime("call", "spacetime-control", reducer, *map(json.dumps, args)) + + def login_with(self, identity_and_token: dict): + self.spacetime("logout") + config = toml.load(self.config_path) + config['spacetimedb_token'] = identity_and_token['token'] + with open(self.config_path, 'w') as f: + toml.dump(config, f) + + def publish_as(self, role_and_token, module, code, clear = False): + print(f"publishing {module} as {role_and_token[0]}:") + print(f"{code}") + self.login_with(role_and_token[1]) + self.write_module_code(code) + self.publish_module(module, clear = clear) + return self.database_identity + + def sql_as(self, role_and_token, database, sql): + """ + Log in as `token` and run an SQL statement against `database` + """ + print(f"running sql as {role_and_token[0]}: {sql}") + self.login_with(role_and_token[1]) + res = self.spacetime("sql", database, sql) + return parse_sql_result(str(res)) + + def subscribe_as(self, role_and_token, *queries, n): + """ + Log in as `token` and subscribe to the current database using `queries`. + """ + print(f"subscribe as {role_and_token[0]}: {queries}") + self.login_with(role_and_token[1]) + return self.subscribe(*queries, n = n) + + +class MutableSql(PermissionsTest): + MODULE_CODE = """ +#[spacetimedb::table(name = person, public)] +struct Person { + name: String, +} +""" + def test_permissions_for_mutable_sql_transactions(self): + """ + Tests that only owners and admins can perform mutable SQL transactions. + """ + + name = random_string() + self.publish_module(name) + team = self.create_collaborators(name) + + for role, token in team.items(): + self.login_with(token) + dml = f"insert into person (name) values ('bob-the-{role}')" + if role == "Owner" or role == "Admin": + self.spacetime("sql", name, dml) + else: + with self.assertRaises(Exception): + self.spacetime("sql", name, dml) + + +class PublishDatabase(PermissionsTest): + MODULE_CODE = """ +#[spacetimedb::table(name = person, public)] +struct Person { + name: String, +} +""" + + MODULE_CODE_OWNER = MODULE_CODE + """ +#[spacetimedb::table(name = owner)] +struct Owner { + name: String, +} +""" + + MODULE_CODE_ADMIN = MODULE_CODE_OWNER + """ +#[spacetimedb::table(name = admin)] +struct Admin { + name: String, +} +""" + + MODULE_CODE_DEVELOPER = MODULE_CODE_ADMIN + """ +#[spacetimedb::table(name = developer)] +struct Developer { + name: String, +} +""" + + MODULE_CODE_VIEWER = MODULE_CODE_DEVELOPER + """ +#[spacetimedb::table(name = viewer)] +struct Viewer { + name: String, +} +""" + + def test_permissions_publish(self): + """ + Tests that only owner, admin and developer roles can publish a database. + """ + + parent = random_string() + self.publish_module(parent) + + (owner, admin, developer, viewer) = self.create_collaborators(parent).items() + succeed_with = [ + (owner, self.MODULE_CODE_OWNER), + (admin, self.MODULE_CODE_ADMIN), + (developer, self.MODULE_CODE_DEVELOPER) + ] + + for role_and_token, code in succeed_with: + self.publish_as(role_and_token, parent, code) + + with self.assertRaises(Exception): + self.publish_as(viewer, parent, self.MODULE_CODE_VIEWER) + + # Create a child database. + child = random_string() + child_path = f"{parent}/{child}" + + # Developer and viewer should not be able to create a child. + for role_and_token in [developer, viewer]: + with self.assertRaises(Exception): + self.publish_as(role_and_token, child_path, self.MODULE_CODE) + # But admin should succeed. + self.publish_as(admin, child_path, self.MODULE_CODE) + + # Once created, only viewer should be denied updating. + for role_and_token, code in succeed_with: + self.publish_as(role_and_token, child_path, code) + + with self.assertRaises(Exception): + self.publish_as(viewer, child_path, self.MODULE_CODE_VIEWER) + + +class ClearDatabase(PermissionsTest): + def test_permissions_clear(self): + """ + Tests that only owners and admins can clear a database. + """ + + parent = random_string() + self.publish_module(parent) + # First degree owner can clear. + self.publish_module(parent, clear = True) + + (owner, admin, developer, viewer) = self.create_collaborators(parent).items() + + # Owner and admin collaborators can clear. + for role_and_token in [owner, admin]: + self.publish_as(role_and_token, parent, self.MODULE_CODE, clear = True) + + # Others can't. + for role_and_token in [developer, viewer]: + with self.assertRaises(Exception): + self.publish_as(role_and_token, parent, self.MODULE_CODE, clear = True) + + # Same applies to child. + child = random_string() + child_path = f"{parent}/{child}" + + self.publish_as(owner, child_path, self.MODULE_CODE) + + for role_and_token in [owner, admin]: + self.publish_as(role_and_token, parent, self.MODULE_CODE, clear = True) + + for role_and_token in [developer, viewer]: + with self.assertRaises(Exception): + self.publish_as(role_and_token, parent, self.MODULE_CODE, clear = True) + + +class DeleteDatabase(PermissionsTest): + def delete_as(self, role_and_token, database): + print(f"delete {database} as {role_and_token[0]}") + self.login_with(role_and_token[1]) + self.spacetime("delete", "--yes", database) + + def test_permissions_delete(self): + """ + Tests that only owners can delete databases. + """ + + parent = random_string() + self.publish_module(parent) + self.spacetime("delete", "--yes", parent) + + self.publish_module(parent) + + (owner, admin, developer, viewer) = self.create_collaborators(parent).items() + + for role_and_token in [admin, developer, viewer]: + with self.assertRaises(Exception): + self.delete_as(role_and_token, parent) + + child = random_string() + child_path = f"{parent}/{child}" + + # If admin creates a child, they should also be able to delete it, + # because they are the owner of the child. + print("publish and delete as admin") + self.publish_as(admin, child_path, self.MODULE_CODE) + self.delete_as(admin, child) + + # The owner role should be able to delete. + print("publish as admin, delete as owner") + self.publish_as(admin, child_path, self.MODULE_CODE) + self.delete_as(owner, child) + + # Anyone else should be denied if not direct owner. + print("publish as owner, deny deletion by admin, developer, viewer") + self.publish_as(owner, child_path, self.MODULE_CODE) + for role_and_token in [admin, developer, viewer]: + with self.assertRaises(Exception): + self.delete_as(role_and_token, child) + + print("delete child as owner") + self.delete_as(owner, child) + + print("delete parent as owner") + self.delete_as(owner, parent) + + +class PrivateTables(PermissionsTest): + def test_permissions_private_tables(self): + """ + Test that all collaborators can read private tables. + """ + + parent = random_string() + self.publish_module(parent) + + team = self.create_collaborators(parent) + owner = ("Owner", team['Owner']) + + self.sql_as(owner, parent, "insert into person (name) values ('horsti')") + + for role_and_token in team.items(): + rows = self.sql_as(role_and_token, parent, "select * from person") + self.assertEqual(rows, [ + { "name": '"horsti"' } + ]) + + for role_and_token in team.items(): + sub = self.subscribe_as(role_and_token, "select * from person", n = 2) + self.sql_as(owner, parent, "insert into person (name) values ('hansmans')") + self.sql_as(owner, parent, "delete from person where name = 'hansmans'") + self.assertEqual( + sub(), + [{ + 'person': { + 'deletes': [{'name': 'hansmans'}], + 'inserts': [{'name': 'hansmans'}] + } + }] + ) From 00977ffd7437ee9da766b272751b3c3811151eeb Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Wed, 22 Oct 2025 11:02:17 +0200 Subject: [PATCH 14/19] Force an AuthCtx down subscriptions --- crates/client-api/src/routes/subscribe.rs | 9 +- crates/core/src/client/client_connection.rs | 26 ++- crates/core/src/db/relational_db.rs | 4 + crates/core/src/host/host_controller.rs | 7 +- .../subscription/module_subscription_actor.rs | 165 ++++++++++++------ crates/lib/src/identity.rs | 40 ++--- crates/standalone/src/lib.rs | 5 +- crates/vm/src/expr.rs | 2 +- smoketests/tests/teams.py | 25 ++- 9 files changed, 180 insertions(+), 103 deletions(-) diff --git a/crates/client-api/src/routes/subscribe.rs b/crates/client-api/src/routes/subscribe.rs index 457fb0bf96b..1a53b578786 100644 --- a/crates/client-api/src/routes/subscribe.rs +++ b/crates/client-api/src/routes/subscribe.rs @@ -49,7 +49,7 @@ use crate::util::websocket::{ CloseCode, CloseFrame, Message as WsMessage, WebSocketConfig, WebSocketStream, WebSocketUpgrade, WsError, }; use crate::util::{NameOrIdentity, XForwardedFor}; -use crate::{log_and_500, ControlStateDelegate, NodeDelegate}; +use crate::{log_and_500, Authorization, ControlStateDelegate, NodeDelegate, Unauthorized}; #[allow(clippy::declare_interior_mutable_const)] pub const TEXT_PROTOCOL: HeaderValue = HeaderValue::from_static(ws_api::TEXT_PROTOCOL); @@ -106,7 +106,7 @@ pub async fn handle_websocket( ws: WebSocketUpgrade, ) -> axum::response::Result where - S: NodeDelegate + ControlStateDelegate + HasWebSocketOptions, + S: NodeDelegate + ControlStateDelegate + HasWebSocketOptions + Authorization, { if connection_id.is_some() { // TODO: Bump this up to `log::warn!` after removing the client SDKs' uses of that parameter. @@ -125,6 +125,10 @@ where } let db_identity = name_or_identity.resolve(&ctx).await?; + let sql_auth = ctx + .authorize_sql(auth.claims.identity, db_identity) + .await + .map_err(Unauthorized::into_response)?; let (res, ws_upgrade, protocol) = ws.select_protocol([(BIN_PROTOCOL, Protocol::Binary), (TEXT_PROTOCOL, Protocol::Text)]); @@ -218,6 +222,7 @@ where let client = ClientConnection::spawn( client_id, auth.into(), + sql_auth, client_config, leader.replica_id, module_rx, diff --git a/crates/core/src/client/client_connection.rs b/crates/core/src/client/client_connection.rs index 85d43482d16..ae8d9da075a 100644 --- a/crates/core/src/client/client_connection.rs +++ b/crates/core/src/client/client_connection.rs @@ -28,7 +28,7 @@ use spacetimedb_client_api_messages::websocket::{ UnsubscribeMulti, }; use spacetimedb_durability::{DurableOffset, TxOffset}; -use spacetimedb_lib::identity::RequestId; +use spacetimedb_lib::identity::{AuthCtx, RequestId}; use spacetimedb_lib::metrics::ExecutionMetrics; use spacetimedb_lib::Identity; use tokio::sync::mpsc::error::{SendError, TrySendError}; @@ -423,6 +423,7 @@ pub struct ClientConnection { sender: Arc, pub replica_id: u64, module_rx: watch::Receiver, + auth: AuthCtx, } impl Deref for ClientConnection { @@ -674,9 +675,11 @@ impl ClientConnection { /// to verify that the database at `module_rx` approves of this connection, /// and should not invoke this method if that call returns an error, /// and pass the returned [`Connected`] as `_proof_of_client_connected_call`. + #[allow(clippy::too_many_arguments)] pub async fn spawn( id: ClientActorId, auth: ConnectionAuthCtx, + sql_auth: AuthCtx, config: ClientConfig, replica_id: u64, mut module_rx: watch::Receiver, @@ -734,6 +737,7 @@ impl ClientConnection { sender, replica_id, module_rx, + auth: sql_auth, }; let actor_fut = actor(this.clone(), receiver); @@ -749,10 +753,12 @@ impl ClientConnection { replica_id: u64, module_rx: watch::Receiver, ) -> Self { + let auth = AuthCtx::new(module_rx.borrow().database_info().database_identity, id.identity); Self { sender: Arc::new(ClientConnectionSender::dummy(id, config, module_rx.clone())), replica_id, module_rx, + auth, } } @@ -842,9 +848,13 @@ impl ClientConnection { let me = self.clone(); self.module() .on_module_thread("subscribe_single", move || { - me.module() - .subscriptions() - .add_single_subscription(me.sender, subscription, timer, None) + me.module().subscriptions().add_single_subscription( + me.sender, + me.auth.clone(), + subscription, + timer, + None, + ) }) .await? } @@ -854,7 +864,7 @@ impl ClientConnection { asyncify(move || { me.module() .subscriptions() - .remove_single_subscription(me.sender, request, timer) + .remove_single_subscription(me.sender, me.auth.clone(), request, timer) }) .await } @@ -869,7 +879,7 @@ impl ClientConnection { .on_module_thread("subscribe_multi", move || { me.module() .subscriptions() - .add_multi_subscription(me.sender, request, timer, None) + .add_multi_subscription(me.sender, me.auth.clone(), request, timer, None) }) .await? } @@ -884,7 +894,7 @@ impl ClientConnection { .on_module_thread("unsubscribe_multi", move || { me.module() .subscriptions() - .remove_multi_subscription(me.sender, request, timer) + .remove_multi_subscription(me.sender, me.auth.clone(), request, timer) }) .await? } @@ -894,7 +904,7 @@ impl ClientConnection { asyncify(move || { me.module() .subscriptions() - .add_legacy_subscriber(me.sender, subscription, timer, None) + .add_legacy_subscriber(me.sender, me.auth.clone(), subscription, timer, None) }) .await } diff --git a/crates/core/src/db/relational_db.rs b/crates/core/src/db/relational_db.rs index 09f1f5934be..3fa49708fa6 100644 --- a/crates/core/src/db/relational_db.rs +++ b/crates/core/src/db/relational_db.rs @@ -623,6 +623,10 @@ impl RelationalDB { self.database_identity } + pub fn owner_identity(&self) -> Identity { + self.owner_identity + } + /// The number of bytes on disk occupied by the durability layer. /// /// If this is an in-memory instance, `Ok(0)` is returned. diff --git a/crates/core/src/host/host_controller.rs b/crates/core/src/host/host_controller.rs index f508bbbc5be..9e3db3b57cf 100644 --- a/crates/core/src/host/host_controller.rs +++ b/crates/core/src/host/host_controller.rs @@ -542,12 +542,7 @@ async fn make_replica_ctx( send_worker_queue.clone(), ))); let downgraded = Arc::downgrade(&subscriptions); - let subscriptions = ModuleSubscriptions::new( - relational_db.clone(), - subscriptions, - send_worker_queue, - database.owner_identity, - ); + let subscriptions = ModuleSubscriptions::new(relational_db.clone(), subscriptions, send_worker_queue); // If an error occurs when evaluating a subscription, // we mark each client that was affected, diff --git a/crates/core/src/subscription/module_subscription_actor.rs b/crates/core/src/subscription/module_subscription_actor.rs index 4b75c3a00eb..1eb25f8a439 100644 --- a/crates/core/src/subscription/module_subscription_actor.rs +++ b/crates/core/src/subscription/module_subscription_actor.rs @@ -50,7 +50,6 @@ pub struct ModuleSubscriptions { /// You will deadlock otherwise. subscriptions: Subscriptions, broadcast_queue: BroadcastQueue, - owner_identity: Identity, stats: Arc, } @@ -179,7 +178,6 @@ impl ModuleSubscriptions { relational_db: Arc, subscriptions: Subscriptions, broadcast_queue: BroadcastQueue, - owner_identity: Identity, ) -> Self { let db = &relational_db.database_identity(); let stats = Arc::new(SubscriptionGauges::new(db)); @@ -188,7 +186,6 @@ impl ModuleSubscriptions { relational_db, subscriptions, broadcast_queue, - owner_identity, stats, } } @@ -209,7 +206,6 @@ impl ModuleSubscriptions { db, SubscriptionManager::for_test_without_metrics_arc_rwlock(), send_worker_queue, - Identity::ZERO, ) } @@ -307,6 +303,7 @@ impl ModuleSubscriptions { pub fn add_single_subscription( &self, sender: Arc, + auth: AuthCtx, request: SubscribeSingle, timer: Instant, _assert: Option, @@ -329,7 +326,6 @@ impl ModuleSubscriptions { }; let sql = request.query; - let auth = AuthCtx::new(self.owner_identity, sender.id.identity); let hash = QueryHash::from_string(&sql, auth.caller(), false); let hash_with_param = QueryHash::from_string(&sql, auth.caller(), true); @@ -398,6 +394,7 @@ impl ModuleSubscriptions { pub fn remove_single_subscription( &self, sender: Arc, + auth: AuthCtx, request: Unsubscribe, timer: Instant, ) -> Result, DBError> { @@ -435,7 +432,6 @@ impl ModuleSubscriptions { }; let (tx, tx_offset) = self.begin_tx(Workload::Unsubscribe); - let auth = AuthCtx::new(self.owner_identity, sender.id.identity); let (table_rows, metrics) = return_on_err_with_sql!( self.evaluate_initial_subscription(sender.clone(), query.clone(), &tx, &auth, TableUpdateType::Unsubscribe), query.sql(), @@ -471,6 +467,7 @@ impl ModuleSubscriptions { pub fn remove_multi_subscription( &self, sender: Arc, + auth: AuthCtx, request: UnsubscribeMulti, timer: Instant, ) -> Result, DBError> { @@ -518,7 +515,7 @@ impl ModuleSubscriptions { sender.clone(), &removed_queries, &tx, - &AuthCtx::new(self.owner_identity, sender.id.identity), + &auth, TableUpdateType::Unsubscribe, ), send_err_msg, @@ -567,6 +564,7 @@ impl ModuleSubscriptions { fn compile_queries( &self, sender: Identity, + auth: AuthCtx, queries: &[Box], num_queries: usize, metrics: &SubscriptionMetrics, @@ -586,8 +584,6 @@ impl ModuleSubscriptions { query_hashes.push((sql, hash, hash_with_param)); } - let auth = AuthCtx::new(self.owner_identity, sender); - // We always get the db lock before the subscription lock to avoid deadlocks. let (tx, _tx_offset) = self.begin_tx(Workload::Subscribe); @@ -651,6 +647,7 @@ impl ModuleSubscriptions { pub fn add_multi_subscription( &self, sender: Arc, + auth: AuthCtx, request: SubscribeMulti, timer: Instant, _assert: Option, @@ -683,6 +680,7 @@ impl ModuleSubscriptions { let (queries, auth, tx, compile_timer) = return_on_err!( self.compile_queries( sender.id.identity, + auth, &request.query_strings, num_queries, &subscription_metrics @@ -765,6 +763,7 @@ impl ModuleSubscriptions { pub fn add_legacy_subscriber( &self, sender: Arc, + auth: AuthCtx, subscription: Subscribe, timer: Instant, _assert: Option, @@ -778,6 +777,7 @@ impl ModuleSubscriptions { let (queries, auth, tx, compile_timer) = self.compile_queries( sender.id.identity, + auth, &subscription.query_strings, num_queries, &subscription_metrics, @@ -1069,14 +1069,14 @@ mod tests { db.clone(), SubscriptionManager::for_test_without_metrics_arc_rwlock(), send_worker_queue, - owner, ); + let auth = AuthCtx::new(owner, sender.auth.claims.identity); let subscribe = Subscribe { query_strings: [sql.into()].into(), request_id: 0, }; - module_subscriptions.add_legacy_subscriber(sender, subscribe, Instant::now(), assert)?; + module_subscriptions.add_legacy_subscriber(sender, auth, subscribe, Instant::now(), assert)?; Ok(()) } @@ -1315,25 +1315,27 @@ mod tests { /// Subscribe to a query as a client fn subscribe_single( subs: &ModuleSubscriptions, + auth: AuthCtx, sql: &'static str, sender: Arc, counter: &mut u32, ) -> anyhow::Result<()> { *counter += 1; - subs.add_single_subscription(sender, single_subscribe(sql, *counter), Instant::now(), None)?; + subs.add_single_subscription(sender, auth, single_subscribe(sql, *counter), Instant::now(), None)?; Ok(()) } /// Subscribe to a set of queries as a client fn subscribe_multi( subs: &ModuleSubscriptions, + auth: AuthCtx, queries: &[&'static str], sender: Arc, counter: &mut u32, ) -> anyhow::Result { *counter += 1; let metrics = subs - .add_multi_subscription(sender, multi_subscribe(queries, *counter), Instant::now(), None) + .add_multi_subscription(sender, auth, multi_subscribe(queries, *counter), Instant::now(), None) .map(|metrics| metrics.unwrap_or_default())?; Ok(metrics) } @@ -1341,20 +1343,22 @@ mod tests { /// Unsubscribe from a single query fn unsubscribe_single( subs: &ModuleSubscriptions, + auth: AuthCtx, sender: Arc, query_id: u32, ) -> anyhow::Result<()> { - subs.remove_single_subscription(sender, single_unsubscribe(query_id), Instant::now())?; + subs.remove_single_subscription(sender, auth, single_unsubscribe(query_id), Instant::now())?; Ok(()) } /// Unsubscribe from a set of queries fn unsubscribe_multi( subs: &ModuleSubscriptions, + auth: AuthCtx, sender: Arc, query_id: u32, ) -> anyhow::Result<()> { - subs.remove_multi_subscription(sender, multi_unsubscribe(query_id), Instant::now())?; + subs.remove_multi_subscription(sender, auth, multi_unsubscribe(query_id), Instant::now())?; Ok(()) } @@ -1536,13 +1540,14 @@ mod tests { let client_id = client_id_from_u8(1); let (tx, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); db.create_table_for_test("t", &[("x", AlgebraicType::U8)], &[])?; // Subscribe to an invalid query (r is not in scope) let sql = "select r.* from t"; - subscribe_single(&subs, sql, tx, &mut 0)?; + subscribe_single(&subs, auth, sql, tx, &mut 0)?; check_subscription_err(sql, rx.recv().await); @@ -1557,13 +1562,14 @@ mod tests { let client_id = client_id_from_u8(1); let (tx, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); db.create_table_for_test("t", &[("x", AlgebraicType::U8)], &[])?; // Subscribe to an invalid query (r is not in scope) let sql = "select r.* from t"; - subscribe_multi(&subs, &[sql], tx, &mut 0)?; + subscribe_multi(&subs, auth, &[sql], tx, &mut 0)?; check_subscription_err(sql, rx.recv().await); @@ -1578,6 +1584,7 @@ mod tests { let client_id = client_id_from_u8(1); let (tx, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); // Create a table `t` with an index on `id` @@ -1596,7 +1603,7 @@ mod tests { // Subscribe to `t` let sql = "select * from t where id = 1"; - subscribe_single(&subs, sql, tx.clone(), &mut query_id)?; + subscribe_single(&subs, auth.clone(), sql, tx.clone(), &mut query_id)?; // The initial subscription should succeed assert!(matches!( @@ -1611,7 +1618,7 @@ mod tests { with_auto_commit(&db, |tx| db.drop_index(tx, index_id))?; // Unsubscribe from `t` - unsubscribe_single(&subs, tx, query_id)?; + unsubscribe_single(&subs, auth, tx, query_id)?; // Why does the unsubscribe fail? // This relies on some knowledge of the underlying implementation. @@ -1633,6 +1640,7 @@ mod tests { let client_id = client_id_from_u8(1); let (tx, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); // Create a table `t` with an index on `id` @@ -1653,7 +1661,7 @@ mod tests { // Subscribe to `t` let sql = "select * from t where id = 1"; - subscribe_multi(&subs, &[sql], tx.clone(), &mut query_id)?; + subscribe_multi(&subs, auth.clone(), &[sql], tx.clone(), &mut query_id)?; // The initial subscription should succeed assert!(matches!( @@ -1668,7 +1676,7 @@ mod tests { with_auto_commit(&db, |tx| db.drop_index(tx, index_id))?; // Unsubscribe from `t` - unsubscribe_multi(&subs, tx, query_id)?; + unsubscribe_multi(&subs, auth, tx, query_id)?; // Why does the unsubscribe fail? // This relies on some knowledge of the underlying implementation. @@ -1688,6 +1696,7 @@ mod tests { let client_id = client_id_from_u8(1); let (tx, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); // Create two tables `t` and `s` with indexes on their `id` columns @@ -1703,7 +1712,7 @@ mod tests { }) })?; let sql = "select t.* from t join s on t.id = s.id"; - subscribe_single(&subs, sql, tx, &mut 0)?; + subscribe_single(&subs, auth, sql, tx, &mut 0)?; // The initial subscription should succeed assert!(matches!( @@ -1752,6 +1761,7 @@ mod tests { let (tx_for_a, mut rx_for_a) = client_connection(client_id_for_a, &db); let (tx_for_b, mut rx_for_b) = client_connection(client_id_for_b, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let schema = [("identity", AlgebraicType::identity())]; @@ -1764,12 +1774,14 @@ mod tests { // Each client should receive different rows. subscribe_multi( &subs, + auth.clone(), &["select * from t where identity = :sender"], tx_for_a, &mut query_ids, )?; subscribe_multi( &subs, + auth, &["select * from t where identity = :sender"], tx_for_b, &mut query_ids, @@ -1819,6 +1831,7 @@ mod tests { let (tx_for_a, mut rx_for_a) = client_connection(client_id_for_a, &db); let (tx_for_b, mut rx_for_b) = client_connection(client_id_for_b, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let schema = [("id", AlgebraicType::identity())]; @@ -1843,8 +1856,8 @@ mod tests { // Have each client subscribe to `w`. // Because `w` is gated using parameterized RLS rules, // each client should receive different rows. - subscribe_multi(&subs, &["select * from w"], tx_for_a, &mut query_ids)?; - subscribe_multi(&subs, &["select * from w"], tx_for_b, &mut query_ids)?; + subscribe_multi(&subs, auth.clone(), &["select * from w"], tx_for_a, &mut query_ids)?; + subscribe_multi(&subs, auth, &["select * from w"], tx_for_b, &mut query_ids)?; // Wait for both subscriptions assert!(matches!( @@ -1887,6 +1900,7 @@ mod tests { let (tx_for_a, mut rx_for_a) = client_connection(client_id_from_u8(0), &db); let (tx_for_b, mut rx_for_b) = client_connection(client_id_from_u8(1), &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); // Create table `t` @@ -1898,8 +1912,8 @@ mod tests { let mut query_ids = 0; // Have owner and client subscribe to `t` - subscribe_multi(&subs, &["select * from t"], tx_for_a, &mut query_ids)?; - subscribe_multi(&subs, &["select * from t"], tx_for_b, &mut query_ids)?; + subscribe_multi(&subs, auth.clone(), &["select * from t"], tx_for_a, &mut query_ids)?; + subscribe_multi(&subs, auth, &["select * from t"], tx_for_b, &mut query_ids)?; // Wait for both subscriptions assert_matches!( @@ -1964,6 +1978,7 @@ mod tests { // Establish a client connection let (tx, mut rx) = client_connection(client_id_from_u8(1), &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let schema = [("x", AlgebraicType::U8)]; @@ -1971,7 +1986,7 @@ mod tests { let t_id = db.create_table_for_test("t", &schema, &[])?; // Subscribe to rows of `t` where `x` is 0 - subscribe_multi(&subs, &["select * from t where x = 0"], tx, &mut 0)?; + subscribe_multi(&subs, auth, &["select * from t where x = 0"], tx, &mut 0)?; // Wait to receive the initial subscription message assert!(matches!(rx.recv().await, Some(SerializableMessage::Subscription(_)))); @@ -2014,6 +2029,7 @@ mod tests { // Establish a client connection with compression let (tx, mut rx) = client_connection_with_compression(client_id_from_u8(1), &db, Compression::Brotli); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let table_id = db.create_table_for_test("t", &[("x", AlgebraicType::U64)], &[])?; @@ -2029,7 +2045,7 @@ mod tests { commit_tx(&db, &subs, [], inserts)?; // Subscribe to the entire table - subscribe_multi(&subs, &["select * from t"], tx, &mut 0)?; + subscribe_multi(&subs, auth, &["select * from t"], tx, &mut 0)?; // Assert the table updates within this message are all be uncompressed match rx.recv().await { @@ -2057,14 +2073,16 @@ mod tests { let db = relational_db()?; // Establish a client connection - let (tx, mut rx) = client_connection(client_id_from_u8(1), &db); + let client_id = client_id_from_u8(1); + let (tx, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let schema = [("x", AlgebraicType::U8), ("y", AlgebraicType::U8)]; let t_id = db.create_table_for_test("t", &schema, &[])?; // Subscribe to `t` - subscribe_multi(&subs, &["select * from t"], tx, &mut 0)?; + subscribe_multi(&subs, auth, &["select * from t"], tx, &mut 0)?; // Wait to receive the initial subscription message assert_matches!(rx.recv().await, Some(SerializableMessage::Subscription(_))); @@ -2077,7 +2095,7 @@ mod tests { run( &db, "INSERT INTO t (x, y) VALUES (0, 1)", - auth, + auth.clone(), Some(&subs), &mut vec![], )?; @@ -2085,7 +2103,13 @@ mod tests { // Client should receive insert assert_tx_update_for_table(rx.recv(), t_id, &schema, [product![0_u8, 1_u8]], []).await; - run(&db, "UPDATE t SET y=2 WHERE x=0", auth, Some(&subs), &mut vec![])?; + run( + &db, + "UPDATE t SET y=2 WHERE x=0", + auth.clone(), + Some(&subs), + &mut vec![], + )?; // Client should receive update assert_tx_update_for_table(rx.recv(), t_id, &schema, [product![0_u8, 2_u8]], [product![0_u8, 1_u8]]).await; @@ -2105,8 +2129,10 @@ mod tests { let db = relational_db()?; // Establish a client connection with compression - let (tx, mut rx) = client_connection_with_compression(client_id_from_u8(1), &db, Compression::Brotli); + let client_id = client_id_from_u8(1); + let (tx, mut rx) = client_connection_with_compression(client_id, &db, Compression::Brotli); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let table_id = db.create_table_for_test("t", &[("x", AlgebraicType::U64)], &[])?; @@ -2118,7 +2144,7 @@ mod tests { } // Subscribe to the entire table - subscribe_multi(&subs, &["select * from t"], tx, &mut 0)?; + subscribe_multi(&subs, auth, &["select * from t"], tx, &mut 0)?; // Wait to receive the initial subscription message assert!(matches!(rx.recv().await, Some(SerializableMessage::Subscription(_)))); @@ -2156,8 +2182,10 @@ mod tests { let db = relational_db()?; // Establish a client connection - let (sender, mut rx) = client_connection(client_id_from_u8(1), &db); + let client_id = client_id_from_u8(1); + let (sender, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let p_schema = [("id", AlgebraicType::U64), ("signed_in", AlgebraicType::Bool)]; @@ -2170,7 +2198,7 @@ mod tests { let p_id = db.create_table_for_test("p", &p_schema, &[0.into()])?; let l_id = db.create_table_for_test("l", &l_schema, &[0.into()])?; - subscribe_multi(&subs, queries, sender, &mut 0)?; + subscribe_multi(&subs, auth, queries, sender, &mut 0)?; assert!(matches!(rx.recv().await, Some(SerializableMessage::Subscription(_)))); @@ -2269,10 +2297,14 @@ mod tests { async fn test_query_pruning() -> anyhow::Result<()> { let db = relational_db()?; + let client_id_a = client_id_from_u8(1); + let client_id_b = client_id_from_u8(2); // Establish a connection for each client - let (tx_for_a, mut rx_for_a) = client_connection(client_id_from_u8(1), &db); - let (tx_for_b, mut rx_for_b) = client_connection(client_id_from_u8(2), &db); + let (tx_for_a, mut rx_for_a) = client_connection(client_id_a, &db); + let (tx_for_b, mut rx_for_b) = client_connection(client_id_b, &db); + let auth_a = AuthCtx::new(db.owner_identity(), client_id_a.identity); + let auth_b = AuthCtx::new(db.owner_identity(), client_id_b.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let u_id = db.create_table_for_test( @@ -2312,6 +2344,7 @@ mod tests { // Returns (i: 0, a: 1, b: 1) subscribe_multi( &subs, + auth_a.clone(), &[ "select u.* from u join v on u.i = v.i where v.x = 4", "select u.* from u join v on u.i = v.i where v.x = 6", @@ -2323,6 +2356,7 @@ mod tests { // Returns (i: 1, a: 2, b: 2) subscribe_multi( &subs, + auth_b.clone(), &[ "select u.* from u join v on u.i = v.i where v.x = 5", "select u.* from u join v on u.i = v.i where v.x = 7", @@ -2411,8 +2445,10 @@ mod tests { async fn test_join_pruning() -> anyhow::Result<()> { let db = relational_db()?; - let (tx, mut rx) = client_connection(client_id_from_u8(1), &db); + let client_id = client_id_from_u8(1); + let (tx, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let u_id = db.create_table_for_test_with_the_works( @@ -2459,6 +2495,7 @@ mod tests { subscribe_multi( &subs, + auth, &[ "select u.* from u join v on u.i = v.i where v.x = 1", "select u.* from u join v on u.i = v.i where v.x = 2", @@ -2565,9 +2602,14 @@ mod tests { async fn test_subscribe_distinct_queries_same_plan() -> anyhow::Result<()> { let db = relational_db()?; + let client_id_a = client_id_from_u8(1); + let client_id_b = client_id_from_u8(2); // Establish a connection for each client - let (tx_for_a, mut rx_for_a) = client_connection(client_id_from_u8(1), &db); - let (tx_for_b, mut rx_for_b) = client_connection(client_id_from_u8(2), &db); + let (tx_for_a, mut rx_for_a) = client_connection(client_id_a, &db); + let (tx_for_b, mut rx_for_b) = client_connection(client_id_b, &db); + + let auth_a = AuthCtx::new(db.owner_identity(), client_id_a.identity); + let auth_b = AuthCtx::new(db.owner_identity(), client_id_b.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); @@ -2603,12 +2645,14 @@ mod tests { // Both clients subscribe to the same query modulo whitespace subscribe_multi( &subs, + auth_a, &["select u.* from u join v on u.i = v.i where v.x = 1"], tx_for_a, &mut query_ids, )?; subscribe_multi( &subs, + auth_b, &["select u.* from u join v on u.i = v.i where v.x = 1"], tx_for_b.clone(), &mut query_ids, @@ -2659,9 +2703,15 @@ mod tests { async fn test_unsubscribe_distinct_queries_same_plan() -> anyhow::Result<()> { let db = relational_db()?; + let client_id_a = client_id_from_u8(1); + let client_id_b = client_id_from_u8(2); + // Establish a connection for each client - let (tx_for_a, mut rx_for_a) = client_connection(client_id_from_u8(1), &db); - let (tx_for_b, mut rx_for_b) = client_connection(client_id_from_u8(2), &db); + let (tx_for_a, mut rx_for_a) = client_connection(client_id_a, &db); + let (tx_for_b, mut rx_for_b) = client_connection(client_id_b, &db); + + let auth_a = AuthCtx::new(db.owner_identity(), client_id_a.identity); + let auth_b = AuthCtx::new(db.owner_identity(), client_id_b.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); @@ -2696,12 +2746,14 @@ mod tests { subscribe_multi( &subs, + auth_a, &["select u.* from u join v on u.i = v.i where v.x = 1"], tx_for_a, &mut query_ids, )?; subscribe_multi( &subs, + auth_b.clone(), &["select u.* from u join v on u.i = v.i where v.x = 1"], tx_for_b.clone(), &mut query_ids, @@ -2723,7 +2775,7 @@ mod tests { })) ); - unsubscribe_multi(&subs, tx_for_b, query_ids)?; + unsubscribe_multi(&subs, auth_b, tx_for_b, query_ids)?; assert_matches!( rx_for_b.recv().await, @@ -2772,8 +2824,10 @@ mod tests { let db = relational_db()?; // Establish a client connection - let (tx, mut rx) = client_connection(client_id_from_u8(1), &db); + let client_id = client_id_from_u8(1); + let (tx, mut rx) = client_connection(client_id, &db); + let auth = AuthCtx::new(db.owner_identity(), client_id.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let schema = &[("id", AlgebraicType::U64), ("a", AlgebraicType::U64)]; @@ -2788,6 +2842,7 @@ mod tests { // Subscribe to queries that return empty results let metrics = subscribe_multi( &subs, + auth, &[ "select t.* from t where a = 0", "select t.* from t join s on t.id = s.id where s.a = 0", @@ -2896,18 +2951,30 @@ mod tests { async fn test_confirmed_reads() -> anyhow::Result<()> { let (db, durability) = relational_db_with_manual_durability()?; + let client_id_confirmed = client_id_from_u8(1); + let client_id_unconfirmed = client_id_from_u8(2); + let (tx_for_confirmed, mut rx_for_confirmed) = - client_connection_with_confirmed_reads(client_id_from_u8(1), &db, true); + client_connection_with_confirmed_reads(client_id_confirmed, &db, true); let (tx_for_unconfirmed, mut rx_for_unconfirmed) = - client_connection_with_confirmed_reads(client_id_from_u8(2), &db, false); + client_connection_with_confirmed_reads(client_id_unconfirmed, &db, false); + + let auth_confirmed = AuthCtx::new(db.owner_identity(), client_id_confirmed.identity); + let auth_unconfirmed = AuthCtx::new(db.owner_identity(), client_id_unconfirmed.identity); let subs = ModuleSubscriptions::for_test_enclosing_runtime(db.clone()); let table = db.create_table_for_test("t", &[("x", AlgebraicType::U8)], &[])?; let schema = ProductType::from([AlgebraicType::U8]); // Subscribe both clients. - subscribe_multi(&subs, &["select * from t"], tx_for_confirmed, &mut 0)?; - subscribe_multi(&subs, &["select * from t"], tx_for_unconfirmed, &mut 0)?; + subscribe_multi(&subs, auth_confirmed, &["select * from t"], tx_for_confirmed, &mut 0)?; + subscribe_multi( + &subs, + auth_unconfirmed, + &["select * from t"], + tx_for_unconfirmed, + &mut 0, + )?; assert_matches!( rx_for_unconfirmed.recv().await, diff --git a/crates/lib/src/identity.rs b/crates/lib/src/identity.rs index ba8a8e8d7bb..1109dad1fc7 100644 --- a/crates/lib/src/identity.rs +++ b/crates/lib/src/identity.rs @@ -17,11 +17,11 @@ pub enum SqlPermission { BypassRLS, } -pub trait ExternalAuthCtx { +pub trait SqlAuthorization { fn has_sql_permission(&self, p: SqlPermission) -> bool; } -impl bool> ExternalAuthCtx for T { +impl bool> SqlAuthorization for T { fn has_sql_permission(&self, p: SqlPermission) -> bool { self(p) } @@ -29,32 +29,29 @@ impl bool> ExternalAuthCtx for T { /// Authorization for SQL operations (queries, DML, subscription queries). #[derive(Clone)] -pub enum AuthCtx { - Simple { - owner: Identity, - caller: Identity, - }, - External { - caller: Identity, - permissions: Arc, - }, +pub struct AuthCtx { + caller: Identity, + permissions: Arc, } impl AuthCtx { pub fn new(owner: Identity, caller: Identity) -> Self { - Self::Simple { owner, caller } + let is_owner = owner == caller; + let permissions = Arc::new(move |_| is_owner); + Self::with_permissions(caller, permissions) + } + + pub fn with_permissions(caller: Identity, permissions: Arc) -> Self { + Self { caller, permissions } } /// For when the owner == caller pub fn for_current(owner: Identity) -> Self { - Self::Simple { owner, caller: owner } + Self::new(owner, owner) } pub fn has_permission(&self, p: SqlPermission) -> bool { - match self { - Self::Simple { owner, caller } => owner == caller, - Self::External { permissions, .. } => permissions.has_sql_permission(p), - } + self.permissions.has_sql_permission(p) } pub fn has_read_access(&self, table_access: StAccess) -> bool { @@ -74,17 +71,12 @@ impl AuthCtx { } pub fn caller(&self) -> Identity { - match self { - Self::Simple { caller, .. } | Self::External { caller, .. } => *caller, - } + self.caller } /// WARNING: Use this only for simple test were the `auth` don't matter pub fn for_testing() -> Self { - Self::Simple { - owner: Identity::__dummy(), - caller: Identity::__dummy(), - } + Self::new(Identity::__dummy(), Identity::__dummy()) } } diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index 59fa9b96f5d..253f4676e59 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -468,10 +468,7 @@ impl spacetimedb_client_api::Authorization for StandaloneEnv { .get_database_by_identity(&database)? .with_context(|| format!("database {database} not found"))?; - Ok(AuthCtx::Simple { - owner: database.owner_identity, - caller: subject, - }) + Ok(AuthCtx::new(database.owner_identity, subject)) } } diff --git a/crates/vm/src/expr.rs b/crates/vm/src/expr.rs index 1583930d30c..23cc59e12d2 100644 --- a/crates/vm/src/expr.rs +++ b/crates/vm/src/expr.rs @@ -2111,7 +2111,7 @@ impl From for CodeResult { mod tests { use super::*; - use spacetimedb_lib::db::raw_def::v9::RawModuleDefV9Builder; + use spacetimedb_lib::{db::raw_def::v9::RawModuleDefV9Builder, Identity}; use spacetimedb_sats::{product, AlgebraicType, ProductType}; use spacetimedb_schema::{def::ModuleDef, relation::Column, schema::Schema}; use typed_arena::Arena; diff --git a/smoketests/tests/teams.py b/smoketests/tests/teams.py index 6cabaa9dfc8..0eeea186865 100644 --- a/smoketests/tests/teams.py +++ b/smoketests/tests/teams.py @@ -309,20 +309,27 @@ def test_permissions_private_tables(self): for role_and_token in team.items(): rows = self.sql_as(role_and_token, parent, "select * from person") - self.assertEqual(rows, [ - { "name": '"horsti"' } - ]) + self.assertEqual(rows, [{ "name": '"horsti"' }]) for role_and_token in team.items(): sub = self.subscribe_as(role_and_token, "select * from person", n = 2) self.sql_as(owner, parent, "insert into person (name) values ('hansmans')") self.sql_as(owner, parent, "delete from person where name = 'hansmans'") + res = sub() self.assertEqual( - sub(), - [{ - 'person': { - 'deletes': [{'name': 'hansmans'}], - 'inserts': [{'name': 'hansmans'}] + res, + [ + { + 'person': { + 'deletes': [], + 'inserts': [{'name': 'hansmans'}] + } + }, + { + 'person': { + 'deletes': [{'name': 'hansmans'}], + 'inserts': [] + } } - }] + ], ) From d869df67a99fdfb6291a97b8d1a7d86f2a06fee5 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Wed, 22 Oct 2025 11:11:20 +0200 Subject: [PATCH 15/19] Funnel AuthCtx to one-off queries --- crates/core/src/client/client_connection.rs | 4 ++-- crates/core/src/host/module_host.rs | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/core/src/client/client_connection.rs b/crates/core/src/client/client_connection.rs index ae8d9da075a..0f3e8c7937c 100644 --- a/crates/core/src/client/client_connection.rs +++ b/crates/core/src/client/client_connection.rs @@ -917,7 +917,7 @@ impl ClientConnection { ) -> Result<(), anyhow::Error> { self.module() .one_off_query::( - self.id.identity, + self.auth.clone(), query.to_owned(), self.sender.clone(), message_id.to_owned(), @@ -935,7 +935,7 @@ impl ClientConnection { ) -> Result<(), anyhow::Error> { self.module() .one_off_query::( - self.id.identity, + self.auth.clone(), query.to_owned(), self.sender.clone(), message_id.to_owned(), diff --git a/crates/core/src/host/module_host.rs b/crates/core/src/host/module_host.rs index 8d03f012f05..0ce571adaad 100644 --- a/crates/core/src/host/module_host.rs +++ b/crates/core/src/host/module_host.rs @@ -1294,7 +1294,7 @@ impl ModuleHost { #[tracing::instrument(level = "trace", skip_all)] pub async fn one_off_query( &self, - caller_identity: Identity, + auth: AuthCtx, query: String, client: Arc, message_id: Vec, @@ -1305,7 +1305,6 @@ impl ModuleHost { let replica_ctx = self.replica_ctx(); let db = replica_ctx.relational_db.clone(); let subscriptions = replica_ctx.subscriptions.clone(); - let auth = AuthCtx::new(replica_ctx.owner_identity, caller_identity); log::debug!("One-off query: {query}"); let metrics = self .on_module_thread("one_off_query", move || { From a5da34794bb931624efa445d3b89928e3c7c3792 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Wed, 22 Oct 2025 14:39:39 +0200 Subject: [PATCH 16/19] Make identity routes customizable, like database routes --- crates/client-api/src/routes/identity.rs | 52 +++++++++++++++++++----- crates/client-api/src/routes/mod.rs | 12 ++++-- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/client-api/src/routes/identity.rs b/crates/client-api/src/routes/identity.rs index be9adde55f9..c8dc44d2964 100644 --- a/crates/client-api/src/routes/identity.rs +++ b/crates/client-api/src/routes/identity.rs @@ -2,6 +2,7 @@ use std::time::Duration; use axum::extract::{Path, State}; use axum::response::IntoResponse; +use axum::routing::MethodRouter; use http::header::CONTENT_TYPE; use http::StatusCode; use serde::{Deserialize, Serialize}; @@ -64,12 +65,12 @@ impl<'de> serde::Deserialize<'de> for IdentityForUrl { #[derive(Deserialize)] pub struct GetDatabasesParams { - identity: IdentityForUrl, + pub identity: IdentityForUrl, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetDatabasesResponse { - identities: Vec, + pub identities: Vec, } pub async fn get_databases( @@ -135,15 +136,46 @@ pub async fn get_public_key(State(ctx): State) -> axum::resp )) } -pub fn router() -> axum::Router +/// A struct to allow customization of the `/identity` routes. +pub struct IdentityRoutes { + /// POST /identity + pub create_post: MethodRouter, + /// GET /identity/public-key + pub public_key_get: MethodRouter, + /// POST /identity/websocket-tocken + pub websocket_token_post: MethodRouter, + /// GET /identity/:identity/verify + pub verify_get: MethodRouter, + /// GET /identity/:identity/databases + pub databases_get: MethodRouter, +} + +impl Default for IdentityRoutes +where + S: NodeDelegate + ControlStateDelegate + Clone + 'static, +{ + fn default() -> Self { + use axum::routing::{get, post}; + Self { + create_post: post(create_identity::), + public_key_get: get(get_public_key::), + websocket_token_post: post(create_websocket_token::), + verify_get: get(validate_token), + databases_get: get(get_databases::), + } + } +} + +impl IdentityRoutes where S: NodeDelegate + ControlStateDelegate + Clone + 'static, { - use axum::routing::{get, post}; - axum::Router::new() - .route("/", post(create_identity::)) - .route("/public-key", get(get_public_key::)) - .route("/websocket-token", post(create_websocket_token::)) - .route("/:identity/verify", get(validate_token)) - .route("/:identity/databases", get(get_databases::)) + pub fn into_router(self) -> axum::Router { + axum::Router::new() + .route("/", self.create_post) + .route("/public-key", self.public_key_get) + .route("/websocket-token", self.websocket_token_post) + .route("/:identity/verify", self.verify_get) + .route("/:identity/databases", self.databases_get) + } } diff --git a/crates/client-api/src/routes/mod.rs b/crates/client-api/src/routes/mod.rs index 940f624e13a..08e1e73cb77 100644 --- a/crates/client-api/src/routes/mod.rs +++ b/crates/client-api/src/routes/mod.rs @@ -1,4 +1,3 @@ -use database::DatabaseRoutes; use http::header; use tower_http::cors; @@ -13,19 +12,26 @@ pub mod metrics; pub mod prometheus; pub mod subscribe; +use self::{database::DatabaseRoutes, identity::IdentityRoutes}; + /// This API call is just designed to allow clients to determine whether or not they can /// establish a connection to SpacetimeDB. This API call doesn't actually do anything. pub async fn ping(_auth: crate::auth::SpacetimeAuthHeader) {} #[allow(clippy::let_and_return)] -pub fn router(ctx: &S, database_routes: DatabaseRoutes, extra: axum::Router) -> axum::Router +pub fn router( + ctx: &S, + database_routes: DatabaseRoutes, + identity_routes: IdentityRoutes, + extra: axum::Router, +) -> axum::Router where S: NodeDelegate + ControlStateDelegate + Authorization + Clone + 'static, { use axum::routing::get; let router = axum::Router::new() .nest("/database", database_routes.into_router(ctx.clone())) - .nest("/identity", identity::router()) + .nest("/identity", identity_routes.into_router()) .nest("/energy", energy::router()) .nest("/prometheus", prometheus::router()) .nest("/metrics", metrics::router()) From fdb5b49c103e86f54fb759847bb85da37f567247 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 23 Oct 2025 18:15:02 +0200 Subject: [PATCH 17/19] Fix legacy SQL permissions --- crates/lib/src/identity.rs | 20 +++++++++++++++----- crates/standalone/src/lib.rs | 6 ++++-- smoketests/tests/permissions.py | 33 +++++++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/lib/src/identity.rs b/crates/lib/src/identity.rs index 1109dad1fc7..968449f7340 100644 --- a/crates/lib/src/identity.rs +++ b/crates/lib/src/identity.rs @@ -27,21 +27,31 @@ impl bool> SqlAuthorization for T { } } +pub type SqlPermissions = Arc; + +/// The legacy permissions (sans "teams") grant everything if the owner is +/// equal to the caller. +fn owner_permissions(owner: Identity, caller: Identity) -> SqlPermissions { + let is_owner = owner == caller; + Arc::new(move |p| match p { + SqlPermission::Read(StAccess::Public) => true, + _ => is_owner, + }) +} + /// Authorization for SQL operations (queries, DML, subscription queries). #[derive(Clone)] pub struct AuthCtx { caller: Identity, - permissions: Arc, + permissions: SqlPermissions, } impl AuthCtx { pub fn new(owner: Identity, caller: Identity) -> Self { - let is_owner = owner == caller; - let permissions = Arc::new(move |_| is_owner); - Self::with_permissions(caller, permissions) + Self::with_permissions(caller, owner_permissions(owner, caller)) } - pub fn with_permissions(caller: Identity, permissions: Arc) -> Self { + pub fn with_permissions(caller: Identity, permissions: SqlPermissions) -> Self { Self { caller, permissions } } diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index 4563095ed68..a3cda83da3b 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -447,7 +447,8 @@ impl spacetimedb_client_api::Authorization for StandaloneEnv { ) -> Result<(), spacetimedb_client_api::Unauthorized> { let database = self .get_database_by_identity(&database)? - .with_context(|| format!("database {database} not found"))?; + .with_context(|| format!("database {database} not found")) + .with_context(|| format!("Unable to authorize {subject} to perform {action:?})"))?; if subject == database.owner_identity { return Ok(()); } @@ -466,7 +467,8 @@ impl spacetimedb_client_api::Authorization for StandaloneEnv { ) -> Result { let database = self .get_database_by_identity(&database)? - .with_context(|| format!("database {database} not found"))?; + .with_context(|| format!("database {database} not found")) + .with_context(|| format!("Unable to authorize {subject} for SQL"))?; Ok(AuthCtx::new(database.owner_identity, subject)) } diff --git a/smoketests/tests/permissions.py b/smoketests/tests/permissions.py index 39ffca6e022..3b1068d0c36 100644 --- a/smoketests/tests/permissions.py +++ b/smoketests/tests/permissions.py @@ -81,7 +81,7 @@ class PrivateTablePermissions(Smoketest): MODULE_CODE = """ use spacetimedb::{ReducerContext, Table}; -#[spacetimedb::table(name = secret)] +#[spacetimedb::table(name = secret, private)] pub struct Secret { answer: u8, } @@ -97,9 +97,9 @@ class PrivateTablePermissions(Smoketest): } #[spacetimedb::reducer] -pub fn do_thing(ctx: &ReducerContext) { +pub fn do_thing(ctx: &ReducerContext, thing: String) { ctx.db.secret().insert(Secret { answer: 20 }); - ctx.db.common_knowledge().insert(CommonKnowledge { thing: "howdy".to_owned() }); + ctx.db.common_knowledge().insert(CommonKnowledge { thing }); } """ @@ -113,7 +113,7 @@ def test_private_table(self): " 42 ", "" ]) - self.assertMultiLineEqual(out, answer) + self.assertMultiLineEqual(str(out), answer) self.reset_config() self.new_identity() @@ -121,12 +121,33 @@ def test_private_table(self): with self.assertRaises(Exception): self.spacetime("sql", self.database_identity, "select * from secret") + # Subscribing to the private table failes. with self.assertRaises(Exception): self.subscribe("SELECT * FROM secret", n=0) + # Subscribing to the public table works. + sub = self.subscribe("SELECT * FROM common_knowledge", n = 1) + self.call("do_thing", "godmorgon") + self.assertEqual(sub(), [ + { + 'common_knowledge': { + 'deletes': [], + 'inserts': [{'thing': 'godmorgon'}] + } + } + ]) + + # Subscribing to both tables returns updates for the public one. sub = self.subscribe("SELECT * FROM *", n=1) - self.call("do_thing", anon=True) - self.assertEqual(sub(), [{'common_knowledge': {'deletes': [], 'inserts': [{'thing': 'howdy'}]}}]) + self.call("do_thing", "howdy", anon=True) + self.assertEqual(sub(), [ + { + 'common_knowledge': { + 'deletes': [], + 'inserts': [{'thing': 'howdy'}] + } + } + ]) class LifecycleReducers(Smoketest): From 450ac67835b41405794853192dbc7e1e15ba7caf Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 23 Oct 2025 18:15:18 +0200 Subject: [PATCH 18/19] Disable assertion that would require knowledge about the edition --- smoketests/tests/domains.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/smoketests/tests/domains.py b/smoketests/tests/domains.py index ca1def69d59..8a39be046d5 100644 --- a/smoketests/tests/domains.py +++ b/smoketests/tests/domains.py @@ -31,7 +31,9 @@ def test_subdomain_behavior(self): root_name = random_string() self.publish_module(root_name) - self.publish_module(f"{root_name}/test") + # TODO: This is valid in editions with the teams feature, but + # smoketests don't know the target's edition. + # self.publish_module(f"{root_name}/test") with self.assertRaises(Exception): self.publish_module(f"{root_name}//test") From 9b8e56475fb2be76b52ce6c72802db994fa09fae Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Fri, 24 Oct 2025 13:59:30 +0200 Subject: [PATCH 19/19] Docs around SQL permissions --- crates/core/src/vm.rs | 2 +- crates/lib/src/identity.rs | 23 +++++++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/core/src/vm.rs b/crates/core/src/vm.rs index 9a21340e811..904fe302cb2 100644 --- a/crates/core/src/vm.rs +++ b/crates/core/src/vm.rs @@ -466,7 +466,7 @@ pub fn check_row_limit( row_est: impl Fn(&Query, &TxId) -> u64, auth: &AuthCtx, ) -> Result<(), DBError> { - if !auth.can_exceed_row_limit() { + if !auth.exceed_row_limit() { if let Some(limit) = db.row_limit(tx)? { let mut estimate: u64 = 0; for query in queries { diff --git a/crates/lib/src/identity.rs b/crates/lib/src/identity.rs index 968449f7340..3a5b2bf0743 100644 --- a/crates/lib/src/identity.rs +++ b/crates/lib/src/identity.rs @@ -10,14 +10,29 @@ use std::{fmt, str::FromStr}; pub type RequestId = u32; +/// Set of permissions the SQL engine may ask for. pub enum SqlPermission { + /// Read permissions given the [StAccess] of a table. + /// + /// [StAccess] must be passed in order to allow external implementations + /// to fail compilation should the [StAccess] enum ever gain additional + /// variants. Implementations should always do an exhaustive match thus. + /// + /// [SqlAuthorization::has_sql_permission] must return true if + /// [StAccess::Public]. Read(StAccess), + /// Write access, i.e. executing DML. Write, + /// If granted, no row limit checks will be performed for subscription queries. ExceedRowLimit, + /// RLS does not apply to database owners (for some definition of owner). + /// If the subject qualifies as an owner, the permission should be granted. BypassRLS, } +/// Types than can grant or deny [SqlPermission]s. pub trait SqlAuthorization { + /// Returns `true` if permission `p` is granted, `false` otherwise. fn has_sql_permission(&self, p: SqlPermission) -> bool; } @@ -27,6 +42,7 @@ impl bool> SqlAuthorization for T { } } +/// [SqlAuthorization] trait object. pub type SqlPermissions = Arc; /// The legacy permissions (sans "teams") grant everything if the owner is @@ -34,7 +50,10 @@ pub type SqlPermissions = Arc; fn owner_permissions(owner: Identity, caller: Identity) -> SqlPermissions { let is_owner = owner == caller; Arc::new(move |p| match p { - SqlPermission::Read(StAccess::Public) => true, + SqlPermission::Read(access) => match access { + StAccess::Public => true, + StAccess::Private => is_owner, + }, _ => is_owner, }) } @@ -72,7 +91,7 @@ impl AuthCtx { self.has_permission(SqlPermission::Write) } - pub fn can_exceed_row_limit(&self) -> bool { + pub fn exceed_row_limit(&self) -> bool { self.has_permission(SqlPermission::ExceedRowLimit) }