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..b7f8a53ef73 --- /dev/null +++ b/crates/cli/src/subcommands/lock.rs @@ -0,0 +1,55 @@ +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..d81939384da 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -612,6 +612,18 @@ 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 +1024,19 @@ 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 +1044,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 +1247,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 +1276,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 +1302,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/standalone/src/control_db.rs b/crates/standalone/src/control_db.rs index b6d9f2821ac..a8d2d310cc6 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)?.map_or(false, |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 {