diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 48b67d5f149..676953577a1 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -38,6 +38,8 @@ pub fn get_subcommands() -> Vec { server::cli(), subscribe::cli(), start::cli(), + lock::cli(), + unlock::cli(), subcommands::version::cli(), ] } @@ -67,6 +69,8 @@ pub async fn exec_subcommand( "start" => return start::exec(paths, args).await, "login" => login::exec(config, args).await, "logout" => logout::exec(config, args).await, + "lock" => lock::exec(config, args).await, + "unlock" => unlock::exec(config, args).await, "version" => return subcommands::version::exec(paths, root_dir, args).await, unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")), } diff --git a/crates/cli/src/subcommands/lock.rs b/crates/cli/src/subcommands/lock.rs new file mode 100644 index 00000000000..4ecaa10d866 --- /dev/null +++ b/crates/cli/src/subcommands/lock.rs @@ -0,0 +1,58 @@ +use crate::common_args; +use crate::config::Config; +use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg}; +use crate::util::{add_auth_header_opt, database_identity, get_auth_header}; +use clap::{Arg, ArgMatches}; + +pub fn cli() -> clap::Command { + clap::Command::new("lock") + .about("Lock a database to prevent accidental deletion") + .long_about( + "Lock a database to prevent it from being deleted.\n\n\ + A locked database cannot be deleted until it is unlocked with `spacetime unlock`.\n\ + This is a safety mechanism to protect production databases from accidental deletion.", + ) + .arg( + Arg::new("database") + .required(false) + .help("The name or identity of the database to lock"), + ) + .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) + .arg( + Arg::new("no_config") + .long("no-config") + .action(clap::ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) + .after_help("Run `spacetime help lock` for more detailed information.\n") +} + +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); + let no_config = args.get_flag("no_config"); + let database_arg = args.get_one::("database").map(|s| s.as_str()); + let config_targets = load_config_db_targets(no_config)?; + let resolved = resolve_database_arg( + database_arg, + config_targets.as_deref(), + "spacetime lock [database] [--no-config]", + )?; + let server = server_from_cli.or(resolved.server.as_deref()); + + let identity = database_identity(&config, &resolved.database, server).await?; + let host_url = config.get_host_url(server)?; + let auth_header = get_auth_header(&mut config, false, server, true).await?; + let client = reqwest::Client::new(); + + let mut builder = client.post(format!("{host_url}/v1/database/{identity}/lock")); + builder = add_auth_header_opt(builder, &auth_header); + + let response = builder.send().await?; + response.error_for_status()?; + + println!( + "Database {} is now locked. It cannot be deleted until unlocked.", + identity + ); + Ok(()) +} diff --git a/crates/cli/src/subcommands/mod.rs b/crates/cli/src/subcommands/mod.rs index 58456274469..ef51b66f055 100644 --- a/crates/cli/src/subcommands/mod.rs +++ b/crates/cli/src/subcommands/mod.rs @@ -8,6 +8,7 @@ pub mod dns; pub mod generate; pub mod init; pub mod list; +pub mod lock; pub mod login; pub mod logout; pub mod logs; @@ -17,4 +18,5 @@ pub mod server; pub mod sql; pub mod start; pub mod subscribe; +pub mod unlock; pub mod version; diff --git a/crates/cli/src/subcommands/unlock.rs b/crates/cli/src/subcommands/unlock.rs new file mode 100644 index 00000000000..76afaacbaa6 --- /dev/null +++ b/crates/cli/src/subcommands/unlock.rs @@ -0,0 +1,54 @@ +use crate::common_args; +use crate::config::Config; +use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg}; +use crate::util::{add_auth_header_opt, database_identity, get_auth_header}; +use clap::{Arg, ArgMatches}; + +pub fn cli() -> clap::Command { + clap::Command::new("unlock") + .about("Unlock a database to allow deletion") + .long_about( + "Unlock a database that was previously locked with `spacetime lock`.\n\n\ + After unlocking, the database can be deleted normally with `spacetime delete`.", + ) + .arg( + Arg::new("database") + .required(false) + .help("The name or identity of the database to unlock"), + ) + .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) + .arg( + Arg::new("no_config") + .long("no-config") + .action(clap::ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) + .after_help("Run `spacetime help unlock` for more detailed information.\n") +} + +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); + let no_config = args.get_flag("no_config"); + let database_arg = args.get_one::("database").map(|s| s.as_str()); + let config_targets = load_config_db_targets(no_config)?; + let resolved = resolve_database_arg( + database_arg, + config_targets.as_deref(), + "spacetime unlock [database] [--no-config]", + )?; + let server = server_from_cli.or(resolved.server.as_deref()); + + let identity = database_identity(&config, &resolved.database, server).await?; + let host_url = config.get_host_url(server)?; + let auth_header = get_auth_header(&mut config, false, server, true).await?; + let client = reqwest::Client::new(); + + let mut builder = client.post(format!("{host_url}/v1/database/{identity}/unlock")); + builder = add_auth_header_opt(builder, &auth_header); + + let response = builder.send().await?; + response.error_for_status()?; + + println!("Database {} is now unlocked.", identity); + Ok(()) +} diff --git a/crates/client-api/src/lib.rs b/crates/client-api/src/lib.rs index fdbf86af36b..f42d4ec4233 100644 --- a/crates/client-api/src/lib.rs +++ b/crates/client-api/src/lib.rs @@ -271,6 +271,9 @@ pub trait ControlStateReadAccess { async fn lookup_database_identity(&self, domain: &str) -> anyhow::Result>; async fn reverse_lookup(&self, database_identity: &Identity) -> anyhow::Result>; async fn lookup_namespace_owner(&self, name: &str) -> anyhow::Result>; + + // Locks + async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result; } /// Write operations on the SpacetimeDB control plane. @@ -327,6 +330,14 @@ pub trait ControlStateWriteAccess: Send + Sync { owner_identity: &Identity, domain_names: &[DomainName], ) -> anyhow::Result; + + // Locks + async fn set_database_lock( + &self, + caller_identity: &Identity, + database_identity: &Identity, + locked: bool, + ) -> anyhow::Result<()>; } #[async_trait] @@ -382,6 +393,10 @@ impl ControlStateReadAc async fn lookup_namespace_owner(&self, name: &str) -> anyhow::Result> { (**self).lookup_namespace_owner(name).await } + + async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result { + (**self).is_database_locked(database_identity).await + } } #[async_trait] @@ -437,6 +452,17 @@ impl ControlStateWriteAccess for Arc { .replace_dns_records(database_identity, owner_identity, domain_names) .await } + + async fn set_database_lock( + &self, + caller_identity: &Identity, + database_identity: &Identity, + locked: bool, + ) -> anyhow::Result<()> { + (**self) + .set_database_lock(caller_identity, database_identity, locked) + .await + } } #[async_trait] diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index e76a474c01f..18952b012d8 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -612,6 +612,14 @@ pub async fn reset( ctx.authorize_action(auth.claims.identity, database.database_identity, Action::ResetDatabase) .await?; + if ctx.is_database_locked(&database_identity).await.map_err(log_and_500)? { + return Err(( + StatusCode::FORBIDDEN, + "Database is locked and cannot be reset with --delete-data. Run `spacetime unlock` first.", + ) + .into()); + } + let num_replicas = num_replicas.map(validate_replication_factor).transpose()?.flatten(); ctx.reset_database( &auth.claims.identity, @@ -1012,6 +1020,15 @@ pub async fn delete_database( ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase) .await?; + + if ctx.is_database_locked(&database_identity).await.map_err(log_and_500)? { + return Err(( + StatusCode::FORBIDDEN, + "Database is locked and cannot be deleted. Run `spacetime unlock` first.", + ) + .into()); + } + ctx.delete_database(&auth.claims.identity, &database_identity) .await .map_err(log_and_500)?; @@ -1019,6 +1036,46 @@ pub async fn delete_database( Ok(()) } +pub async fn lock_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 Err(StatusCode::NOT_FOUND.into()); + }; + + ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase) + .await?; + + ctx.set_database_lock(&auth.claims.identity, &database_identity, true) + .await + .map_err(log_and_500)?; + + Ok(()) +} + +pub async fn unlock_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 Err(StatusCode::NOT_FOUND.into()); + }; + + ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase) + .await?; + + ctx.set_database_lock(&auth.claims.identity, &database_identity, false) + .await + .map_err(log_and_500)?; + + Ok(()) +} + #[derive(Deserialize)] pub struct AddNameParams { name_or_identity: NameOrIdentity, @@ -1182,6 +1239,10 @@ pub struct DatabaseRoutes { pub db_reset: MethodRouter, /// GET: /database/: name_or_identity/unstable/timestamp pub timestamp_get: MethodRouter, + /// POST: /database/:name_or_identity/lock + pub lock_post: MethodRouter, + /// POST: /database/:name_or_identity/unlock + pub unlock_post: MethodRouter, } impl Default for DatabaseRoutes @@ -1207,6 +1268,8 @@ where pre_publish: post(pre_publish::), db_reset: put(reset::), timestamp_get: get(get_timestamp::), + lock_post: post(lock_database::), + unlock_post: post(unlock_database::), } } } @@ -1231,7 +1294,9 @@ where .route("/sql", self.sql_post) .route("/unstable/timestamp", self.timestamp_get) .route("/pre_publish", self.pre_publish) - .route("/reset", self.db_reset); + .route("/reset", self.db_reset) + .route("/lock", self.lock_post) + .route("/unlock", self.unlock_post); axum::Router::new() .route("/", self.root_post) diff --git a/crates/smoketests/tests/smoketests/database_lock.rs b/crates/smoketests/tests/smoketests/database_lock.rs new file mode 100644 index 00000000000..989c9d13ce0 --- /dev/null +++ b/crates/smoketests/tests/smoketests/database_lock.rs @@ -0,0 +1,142 @@ +use spacetimedb_smoketests::Smoketest; + +/// Test that a locked database cannot be deleted. +#[test] +fn test_locked_database_cannot_be_deleted() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Lock the database + test.spacetime(&["lock", "--server", &test.server_url, identity]) + .unwrap(); + + // Try to delete — should fail + let result = test.spacetime(&["delete", "--server", &test.server_url, identity, "--yes"]); + assert!( + result.is_err(), + "Expected delete to fail on a locked database, but it succeeded" + ); +} + +/// Test that a locked database cannot be reset with --delete-data. +#[test] +fn test_locked_database_cannot_be_reset() { + let mut test = Smoketest::builder() + .precompiled_module("modules-basic") + .autopublish(false) + .build(); + + let name = format!("test-lock-reset-{}", std::process::id()); + test.publish_module_named(&name, false).unwrap(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Lock the database + test.spacetime(&["lock", "--server", &test.server_url, identity]) + .unwrap(); + + // Try to republish with --delete-data — should fail + let result = test.publish_module_with_options(&name, true, false); + assert!( + result.is_err(), + "Expected publish with --delete-data to fail on a locked database, but it succeeded" + ); +} + +/// Test that unlocking a locked database allows deletion. +#[test] +fn test_unlock_allows_delete() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Lock the database + test.spacetime(&["lock", "--server", &test.server_url, identity]) + .unwrap(); + + // Verify delete is blocked + let result = test.spacetime(&["delete", "--server", &test.server_url, identity, "--yes"]); + assert!(result.is_err(), "Expected delete to fail while locked"); + + // Unlock the database + test.spacetime(&["unlock", "--server", &test.server_url, identity]) + .unwrap(); + + // Now delete should succeed + test.spacetime(&["delete", "--server", &test.server_url, identity, "--yes"]) + .unwrap(); +} + +/// Test that locking an already-locked database is idempotent. +#[test] +fn test_lock_is_idempotent() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Lock twice — second lock should not error + test.spacetime(&["lock", "--server", &test.server_url, identity]) + .unwrap(); + test.spacetime(&["lock", "--server", &test.server_url, identity]) + .unwrap(); +} + +/// Test that unlocking an already-unlocked database is idempotent. +#[test] +fn test_unlock_is_idempotent() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Unlock without ever locking — should not error + test.spacetime(&["unlock", "--server", &test.server_url, identity]) + .unwrap(); +} + +/// Test that a non-owner cannot lock or unlock a database. +#[test] +fn test_non_owner_cannot_lock_or_unlock() { + let test = Smoketest::builder().precompiled_module("modules-basic").build(); + + let identity = test.database_identity.as_ref().unwrap().clone(); + + // Switch to a new identity + test.new_identity().unwrap(); + + // Non-owner lock should fail + let result = test.spacetime(&["lock", "--server", &test.server_url, &identity]); + assert!( + result.is_err(), + "Expected non-owner lock to fail, but it succeeded" + ); + + // Non-owner unlock should fail + let result = test.spacetime(&["unlock", "--server", &test.server_url, &identity]); + assert!( + result.is_err(), + "Expected non-owner unlock to fail, but it succeeded" + ); +} + +/// Test that publish without --delete-data still works on a locked database. +/// Lock only prevents deletion, not updates. +#[test] +fn test_locked_database_allows_publish() { + let mut test = Smoketest::builder() + .precompiled_module("modules-basic") + .autopublish(false) + .build(); + + let name = format!("test-lock-publish-{}", std::process::id()); + test.publish_module_named(&name, false).unwrap(); + + let identity = test.database_identity.as_ref().unwrap(); + + // Lock the database + test.spacetime(&["lock", "--server", &test.server_url, identity]) + .unwrap(); + + // Republish without --delete-data — should succeed + test.publish_module_clear(false).unwrap(); +} diff --git a/crates/smoketests/tests/smoketests/mod.rs b/crates/smoketests/tests/smoketests/mod.rs index 1f7e53a7230..7918ea0cc47 100644 --- a/crates/smoketests/tests/smoketests/mod.rs +++ b/crates/smoketests/tests/smoketests/mod.rs @@ -7,6 +7,7 @@ mod cli; mod client_connection_errors; mod confirmed_reads; mod connect_disconnect_from_cli; +mod database_lock; mod create_project; mod csharp_module; mod default_module_clippy; diff --git a/crates/standalone/src/control_db.rs b/crates/standalone/src/control_db.rs index b6d9f2821ac..4e6c785599c 100644 --- a/crates/standalone/src/control_db.rs +++ b/crates/standalone/src/control_db.rs @@ -394,6 +394,19 @@ impl ControlDb { Ok(()) } + pub fn is_database_locked(&self, database_identity: &Identity) -> Result { + let tree = self.db.open_tree("database_locks")?; + let key = database_identity.to_be_byte_array(); + Ok(tree.get(key)?.is_some_and(|v| v.as_ref() == [1u8])) + } + + pub fn set_database_lock(&self, database_identity: &Identity, locked: bool) -> Result<()> { + let tree = self.db.open_tree("database_locks")?; + let key = database_identity.to_be_byte_array(); + tree.insert(key, &[locked as u8])?; + 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")?; diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index 6c4e61dc9c8..d01f79f9ae5 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -254,6 +254,10 @@ impl spacetimedb_client_api::ControlStateReadAccess for StandaloneEnv { let name: DatabaseName = name.parse()?; Ok(self.control_db.spacetime_lookup_tld(Tld::from(name))?) } + + async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result { + Ok(self.control_db.is_database_locked(database_identity)?) + } } #[async_trait] @@ -475,6 +479,19 @@ impl spacetimedb_client_api::ControlStateWriteAccess for StandaloneEnv { .control_db .spacetime_replace_domains(database_identity, owner_identity, domain_names)?) } + + async fn set_database_lock( + &self, + _caller_identity: &Identity, + database_identity: &Identity, + locked: bool, + ) -> anyhow::Result<()> { + let Some(_database) = self.control_db.get_database_by_identity(database_identity)? else { + anyhow::bail!("Database not found: {}", database_identity.to_abbreviated_hex()); + }; + self.control_db.set_database_lock(database_identity, locked)?; + Ok(()) + } } impl spacetimedb_client_api::Authorization for StandaloneEnv { diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index cde29f9e372..42688c16eca 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -36,6 +36,8 @@ This document contains the help content for the `spacetime` command-line program * [`spacetime server clear`↴](#spacetime-server-clear) * [`spacetime subscribe`↴](#spacetime-subscribe) * [`spacetime start`↴](#spacetime-start) +* [`spacetime lock`↴](#spacetime-lock) +* [`spacetime unlock`↴](#spacetime-unlock) * [`spacetime version`↴](#spacetime-version) ## `spacetime` @@ -61,6 +63,8 @@ This document contains the help content for the `spacetime` command-line program * `server` — Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. * `subscribe` — Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. * `start` — Start a local SpacetimeDB instance +* `lock` — Lock a database to prevent accidental deletion +* `unlock` — Unlock a database to allow deletion * `version` — Manage installed spacetime versions ###### **Options:** @@ -612,6 +616,51 @@ Run `spacetime start --help` to see all options. +## `spacetime lock` + +Lock a database to prevent it from being deleted. + +A locked database cannot be deleted until it is unlocked with `spacetime unlock`. +This is a safety mechanism to protect production databases from accidental deletion. + +**Usage:** `spacetime lock [OPTIONS] [database]` + +Run `spacetime help lock` for more detailed information. + + +###### **Arguments:** + +* `` — The name or identity of the database to lock + +###### **Options:** + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `--no-config` — Ignore spacetime.json configuration + + + +## `spacetime unlock` + +Unlock a database that was previously locked with `spacetime lock`. + +After unlocking, the database can be deleted normally with `spacetime delete`. + +**Usage:** `spacetime unlock [OPTIONS] [database]` + +Run `spacetime help unlock` for more detailed information. + + +###### **Arguments:** + +* `` — The name or identity of the database to unlock + +###### **Options:** + +* `-s`, `--server ` — The nickname, host name or URL of the server hosting the database +* `--no-config` — Ignore spacetime.json configuration + + + ## `spacetime version` Manage installed spacetime versions