Add spacetime lock/unlock to prevent accidental database deletion (#4502)

## Motivation

Feature request: "Is there any way we can lock a module to prevent it
from being deleted? A bit concerned about some fat finger risk of
accidentally deleting prod."

## Solution

Adds a database lock mechanism. A locked database cannot be deleted
until explicitly unlocked.

### New CLI Commands

```bash
# Lock a database to prevent deletion
spacetime lock my-database

# Attempt to delete a locked database (fails with 403)
spacetime delete my-database
# Error: Database is locked and cannot be deleted. Run \`spacetime unlock\` first.

# Unlock when you actually need to delete
spacetime unlock my-database
spacetime delete my-database
```

Both commands support `--server` and `--no-config` flags, and resolve
the database from `spacetime.json` when no argument is given (same as
`spacetime delete`).

### New HTTP API

- `POST /v1/database/:name_or_identity/lock` -- Lock a database
- `POST /v1/database/:name_or_identity/unlock` -- Unlock a database

Both require the same authorization as `DELETE` (owner only).

### Implementation

- Lock state stored in a separate `database_locks` sled tree in the
standalone control DB (avoids changing the `Database` struct and needing
a data migration)
- `ControlStateReadAccess::is_database_locked()` and
`ControlStateWriteAccess::set_database_lock()` added to the trait
- `delete_database` route checks lock state before proceeding; returns
`403 Forbidden` with a descriptive message if locked
- Locking is idempotent (locking an already-locked database is a no-op,
same for unlock)
- Lock only prevents deletion, not publishing updates

### What is NOT locked

- `spacetime publish` (updating module code) still works on locked
databases
- Only `spacetime delete` is blocked

This matches the intent: protect prod from accidental destruction while
allowing normal deployments.

---------

Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com>
This commit is contained in:
clockwork-labs-bot
2026-04-20 17:03:15 -04:00
committed by GitHub
parent 7726fe807a
commit 81c9eab86c
11 changed files with 426 additions and 1 deletions
+4
View File
@@ -38,6 +38,8 @@ pub fn get_subcommands() -> Vec<Command> {
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}")),
}
+58
View File
@@ -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::<String>("server").map(|s| s.as_ref());
let no_config = args.get_flag("no_config");
let database_arg = args.get_one::<String>("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(())
}
+2
View File
@@ -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;
+54
View File
@@ -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::<String>("server").map(|s| s.as_ref());
let no_config = args.get_flag("no_config");
let database_arg = args.get_one::<String>("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(())
}
+26
View File
@@ -288,6 +288,9 @@ pub trait ControlStateReadAccess {
async fn lookup_database_identity(&self, domain: &str) -> anyhow::Result<Option<Identity>>;
async fn reverse_lookup(&self, database_identity: &Identity) -> anyhow::Result<Vec<DomainName>>;
async fn lookup_namespace_owner(&self, name: &str) -> anyhow::Result<Option<Identity>>;
// Locks
async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result<bool>;
}
/// Write operations on the SpacetimeDB control plane.
@@ -344,6 +347,14 @@ pub trait ControlStateWriteAccess: Send + Sync {
owner_identity: &Identity,
domain_names: &[DomainName],
) -> anyhow::Result<SetDomainsResult>;
// Locks
async fn set_database_lock(
&self,
caller_identity: &Identity,
database_identity: &Identity,
locked: bool,
) -> anyhow::Result<()>;
}
#[async_trait]
@@ -399,6 +410,10 @@ impl<T: ControlStateReadAccess + Send + Sync + Sync + ?Sized> ControlStateReadAc
async fn lookup_namespace_owner(&self, name: &str) -> anyhow::Result<Option<Identity>> {
(**self).lookup_namespace_owner(name).await
}
async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result<bool> {
(**self).is_database_locked(database_identity).await
}
}
#[async_trait]
@@ -454,6 +469,17 @@ impl<T: ControlStateWriteAccess + ?Sized> ControlStateWriteAccess for Arc<T> {
.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]
+66 -1
View File
@@ -653,6 +653,14 @@ pub async fn reset<S: NodeDelegate + ControlStateDelegate + Authorization>(
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,
@@ -1053,6 +1061,15 @@ pub async fn delete_database<S: ControlStateDelegate + Authorization>(
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)?;
@@ -1060,6 +1077,46 @@ pub async fn delete_database<S: ControlStateDelegate + Authorization>(
Ok(())
}
pub async fn lock_database<S: ControlStateDelegate + Authorization>(
State(ctx): State<S>,
Path(DeleteDatabaseParams { name_or_identity }): Path<DeleteDatabaseParams>,
Extension(auth): Extension<SpacetimeAuth>,
) -> axum::response::Result<impl IntoResponse> {
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<S: ControlStateDelegate + Authorization>(
State(ctx): State<S>,
Path(DeleteDatabaseParams { name_or_identity }): Path<DeleteDatabaseParams>,
Extension(auth): Extension<SpacetimeAuth>,
) -> axum::response::Result<impl IntoResponse> {
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,
@@ -1228,6 +1285,10 @@ pub struct DatabaseRoutes<S> {
pub db_reset: MethodRouter<S>,
/// GET: /database/: name_or_identity/unstable/timestamp
pub timestamp_get: MethodRouter<S>,
/// POST: /database/:name_or_identity/lock
pub lock_post: MethodRouter<S>,
/// POST: /database/:name_or_identity/unlock
pub unlock_post: MethodRouter<S>,
}
impl<S> Default for DatabaseRoutes<S>
@@ -1253,6 +1314,8 @@ where
pre_publish: post(pre_publish::<S>),
db_reset: put(reset::<S>),
timestamp_get: get(get_timestamp::<S>),
lock_post: post(lock_database::<S>),
unlock_post: post(unlock_database::<S>),
}
}
}
@@ -1277,7 +1340,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)
@@ -0,0 +1,136 @@
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();
}
@@ -10,6 +10,7 @@ mod confirmed_reads;
mod connect_disconnect_from_cli;
mod create_project;
mod csharp_module;
mod database_lock;
mod default_module_clippy;
mod delete_database;
mod describe;
+13
View File
@@ -394,6 +394,19 @@ impl ControlDb {
Ok(())
}
pub fn is_database_locked(&self, database_identity: &Identity) -> Result<bool> {
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<Option<u64>> {
let tree = self.db.open_tree("database")?;
let tree_by_identity = self.db.open_tree("database_by_identity")?;
+17
View File
@@ -256,6 +256,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<bool> {
Ok(self.control_db.is_database_locked(database_identity)?)
}
}
#[async_trait]
@@ -477,6 +481,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 {
@@ -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:**
@@ -618,6 +622,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:**
* `<DATABASE>` — The name or identity of the database to lock
###### **Options:**
* `-s`, `--server <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:**
* `<DATABASE>` — The name or identity of the database to unlock
###### **Options:**
* `-s`, `--server <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