Skip to content

Commit f8ccbbe

Browse files
clockwork-labs-botcloutiertylerbfops
authored
Re-land spacetime lock/unlock to prevent accidental database deletion (#4888)
Re-lands #4502 on current `master` after the revert in #4881. ## Summary - restore `spacetime lock` / `spacetime unlock` - block deleting locked databases - restore the database-lock smoketests ## Validation - `cargo fmt --all --check` - `cargo check -p spacetimedb-cli -p spacetimedb-client-api -p spacetimedb-standalone` --------- Co-authored-by: clockwork-labs-bot <clockwork-labs-bot@users.noreply.github.com> Co-authored-by: Tyler Cloutier <cloutiertyler@aol.com> Co-authored-by: Tyler Cloutier <cloutiertyler@users.noreply.github.com> Co-authored-by: Zeke Foppa <bfops@users.noreply.github.com>
1 parent fc47257 commit f8ccbbe

11 files changed

Lines changed: 439 additions & 1 deletion

File tree

crates/cli/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ pub fn get_subcommands() -> Vec<Command> {
3838
server::cli(),
3939
subscribe::cli(),
4040
start::cli(),
41+
lock::cli(),
42+
unlock::cli(),
4143
subcommands::version::cli(),
4244
]
4345
}
@@ -67,6 +69,8 @@ pub async fn exec_subcommand(
6769
"start" => return start::exec(config, paths, args).await,
6870
"login" => login::exec(config, args).await,
6971
"logout" => logout::exec(config, args).await,
72+
"lock" => lock::exec(config, args).await,
73+
"unlock" => unlock::exec(config, args).await,
7074
"version" => return subcommands::version::exec(paths, root_dir, args).await,
7175
unknown => Err(anyhow::anyhow!("Invalid subcommand: {unknown}")),
7276
}

crates/cli/src/subcommands/lock.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use crate::common_args;
2+
use crate::config::Config;
3+
use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg};
4+
use crate::util::{add_auth_header_opt, database_identity, get_auth_header};
5+
use clap::{Arg, ArgMatches};
6+
7+
pub fn cli() -> clap::Command {
8+
clap::Command::new("lock")
9+
.about("Lock a database to prevent accidental deletion")
10+
.long_about(
11+
"Lock a database to prevent it from being deleted.\n\n\
12+
A locked database cannot be deleted until it is unlocked with `spacetime unlock`.\n\
13+
This is a safety mechanism to protect production databases from accidental deletion.",
14+
)
15+
.arg(
16+
Arg::new("database")
17+
.required(false)
18+
.help("The name or identity of the database to lock"),
19+
)
20+
.arg(common_args::server().help("The nickname, host name or URL of the server hosting the database"))
21+
.arg(
22+
Arg::new("no_config")
23+
.long("no-config")
24+
.action(clap::ArgAction::SetTrue)
25+
.help("Ignore spacetime.json configuration"),
26+
)
27+
.after_help("Run `spacetime help lock` for more detailed information.\n")
28+
}
29+
30+
pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
31+
let server_from_cli = args.get_one::<String>("server").map(|s| s.as_ref());
32+
let no_config = args.get_flag("no_config");
33+
let database_arg = args.get_one::<String>("database").map(|s| s.as_str());
34+
let config_targets = load_config_db_targets(no_config)?;
35+
let resolved = resolve_database_arg(
36+
database_arg,
37+
config_targets.as_deref(),
38+
"spacetime lock [database] [--no-config]",
39+
)?;
40+
let server = server_from_cli.or(resolved.server.as_deref());
41+
42+
let identity = database_identity(&config, &resolved.database, server).await?;
43+
let host_url = config.get_host_url(server)?;
44+
let auth_header = get_auth_header(&mut config, false, server, true).await?;
45+
let client = reqwest::Client::new();
46+
47+
let mut builder = client.post(format!("{host_url}/v1/database/{identity}/lock"));
48+
builder = add_auth_header_opt(builder, &auth_header);
49+
50+
let response = builder.send().await?;
51+
response.error_for_status()?;
52+
53+
println!(
54+
"Database {} is now locked. It cannot be deleted until unlocked.",
55+
identity
56+
);
57+
Ok(())
58+
}

crates/cli/src/subcommands/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod dns;
88
pub mod generate;
99
pub mod init;
1010
pub mod list;
11+
pub mod lock;
1112
pub mod login;
1213
pub mod logout;
1314
pub mod logs;
@@ -17,4 +18,5 @@ pub mod server;
1718
pub mod sql;
1819
pub mod start;
1920
pub mod subscribe;
21+
pub mod unlock;
2022
pub mod version;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
use crate::common_args;
2+
use crate::config::Config;
3+
use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg};
4+
use crate::util::{add_auth_header_opt, database_identity, get_auth_header};
5+
use clap::{Arg, ArgMatches};
6+
7+
pub fn cli() -> clap::Command {
8+
clap::Command::new("unlock")
9+
.about("Unlock a database to allow deletion")
10+
.long_about(
11+
"Unlock a database that was previously locked with `spacetime lock`.\n\n\
12+
After unlocking, the database can be deleted normally with `spacetime delete`.",
13+
)
14+
.arg(
15+
Arg::new("database")
16+
.required(false)
17+
.help("The name or identity of the database to unlock"),
18+
)
19+
.arg(common_args::server().help("The nickname, host name or URL of the server hosting the database"))
20+
.arg(
21+
Arg::new("no_config")
22+
.long("no-config")
23+
.action(clap::ArgAction::SetTrue)
24+
.help("Ignore spacetime.json configuration"),
25+
)
26+
.after_help("Run `spacetime help unlock` for more detailed information.\n")
27+
}
28+
29+
pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
30+
let server_from_cli = args.get_one::<String>("server").map(|s| s.as_ref());
31+
let no_config = args.get_flag("no_config");
32+
let database_arg = args.get_one::<String>("database").map(|s| s.as_str());
33+
let config_targets = load_config_db_targets(no_config)?;
34+
let resolved = resolve_database_arg(
35+
database_arg,
36+
config_targets.as_deref(),
37+
"spacetime unlock [database] [--no-config]",
38+
)?;
39+
let server = server_from_cli.or(resolved.server.as_deref());
40+
41+
let identity = database_identity(&config, &resolved.database, server).await?;
42+
let host_url = config.get_host_url(server)?;
43+
let auth_header = get_auth_header(&mut config, false, server, true).await?;
44+
let client = reqwest::Client::new();
45+
46+
let mut builder = client.post(format!("{host_url}/v1/database/{identity}/unlock"));
47+
builder = add_auth_header_opt(builder, &auth_header);
48+
49+
let response = builder.send().await?;
50+
response.error_for_status()?;
51+
52+
println!("Database {} is now unlocked.", identity);
53+
Ok(())
54+
}

crates/client-api/src/lib.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ pub trait ControlStateReadAccess {
281281
async fn lookup_database_identity(&self, domain: &str) -> anyhow::Result<Option<Identity>>;
282282
async fn reverse_lookup(&self, database_identity: &Identity) -> anyhow::Result<Vec<DomainName>>;
283283
async fn lookup_namespace_owner(&self, name: &str) -> anyhow::Result<Option<Identity>>;
284+
285+
// Locks
286+
async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result<bool>;
284287
}
285288

286289
/// Write operations on the SpacetimeDB control plane.
@@ -337,6 +340,14 @@ pub trait ControlStateWriteAccess: Send + Sync {
337340
owner_identity: &Identity,
338341
domain_names: &[DomainName],
339342
) -> anyhow::Result<SetDomainsResult>;
343+
344+
// Locks
345+
async fn set_database_lock(
346+
&self,
347+
caller_identity: &Identity,
348+
database_identity: &Identity,
349+
locked: bool,
350+
) -> anyhow::Result<()>;
340351
}
341352

342353
#[async_trait]
@@ -392,6 +403,10 @@ impl<T: ControlStateReadAccess + Send + Sync + Sync + ?Sized> ControlStateReadAc
392403
async fn lookup_namespace_owner(&self, name: &str) -> anyhow::Result<Option<Identity>> {
393404
(**self).lookup_namespace_owner(name).await
394405
}
406+
407+
async fn is_database_locked(&self, database_identity: &Identity) -> anyhow::Result<bool> {
408+
(**self).is_database_locked(database_identity).await
409+
}
395410
}
396411

397412
#[async_trait]
@@ -447,6 +462,17 @@ impl<T: ControlStateWriteAccess + ?Sized> ControlStateWriteAccess for Arc<T> {
447462
.replace_dns_records(database_identity, owner_identity, domain_names)
448463
.await
449464
}
465+
466+
async fn set_database_lock(
467+
&self,
468+
caller_identity: &Identity,
469+
database_identity: &Identity,
470+
locked: bool,
471+
) -> anyhow::Result<()> {
472+
(**self)
473+
.set_database_lock(caller_identity, database_identity, locked)
474+
.await
475+
}
450476
}
451477

452478
#[async_trait]

crates/client-api/src/routes/database.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,14 @@ pub async fn reset<S: NodeDelegate + ControlStateDelegate + Authorization>(
848848
ctx.authorize_action(auth.claims.identity, database.database_identity, Action::ResetDatabase)
849849
.await?;
850850

851+
if ctx.is_database_locked(&database_identity).await.map_err(log_and_500)? {
852+
return Err((
853+
StatusCode::FORBIDDEN,
854+
"Database is locked and cannot be reset with --delete-data. Run `spacetime unlock` first.",
855+
)
856+
.into());
857+
}
858+
851859
let num_replicas = num_replicas.map(validate_replication_factor).transpose()?.flatten();
852860
ctx.reset_database(
853861
&auth.claims.identity,
@@ -1323,13 +1331,62 @@ pub async fn delete_database<S: ControlStateDelegate + Authorization>(
13231331

13241332
ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase)
13251333
.await?;
1334+
1335+
if ctx.is_database_locked(&database_identity).await.map_err(log_and_500)? {
1336+
return Err((
1337+
StatusCode::FORBIDDEN,
1338+
"Database is locked and cannot be deleted. Run `spacetime unlock` first.",
1339+
)
1340+
.into());
1341+
}
1342+
13261343
ctx.delete_database(&auth.claims.identity, &database_identity)
13271344
.await
13281345
.map_err(log_and_500)?;
13291346

13301347
Ok(())
13311348
}
13321349

1350+
pub async fn lock_database<S: ControlStateDelegate + Authorization>(
1351+
State(ctx): State<S>,
1352+
Path(DeleteDatabaseParams { name_or_identity }): Path<DeleteDatabaseParams>,
1353+
Extension(auth): Extension<SpacetimeAuth>,
1354+
) -> axum::response::Result<impl IntoResponse> {
1355+
let database_identity = name_or_identity.resolve(&ctx).await?;
1356+
let Some(_database) = worker_ctx_find_database(&ctx, &database_identity).await? else {
1357+
return Err(StatusCode::NOT_FOUND.into());
1358+
};
1359+
1360+
ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase)
1361+
.await?;
1362+
1363+
ctx.set_database_lock(&auth.claims.identity, &database_identity, true)
1364+
.await
1365+
.map_err(log_and_500)?;
1366+
1367+
Ok(())
1368+
}
1369+
1370+
pub async fn unlock_database<S: ControlStateDelegate + Authorization>(
1371+
State(ctx): State<S>,
1372+
Path(DeleteDatabaseParams { name_or_identity }): Path<DeleteDatabaseParams>,
1373+
Extension(auth): Extension<SpacetimeAuth>,
1374+
) -> axum::response::Result<impl IntoResponse> {
1375+
let database_identity = name_or_identity.resolve(&ctx).await?;
1376+
let Some(_database) = worker_ctx_find_database(&ctx, &database_identity).await? else {
1377+
return Err(StatusCode::NOT_FOUND.into());
1378+
};
1379+
1380+
ctx.authorize_action(auth.claims.identity, database_identity, Action::DeleteDatabase)
1381+
.await?;
1382+
1383+
ctx.set_database_lock(&auth.claims.identity, &database_identity, false)
1384+
.await
1385+
.map_err(log_and_500)?;
1386+
1387+
Ok(())
1388+
}
1389+
13331390
#[derive(Deserialize)]
13341391
pub struct AddNameParams {
13351392
name_or_identity: NameOrIdentity,
@@ -1498,6 +1555,10 @@ pub struct DatabaseRoutes<S> {
14981555
pub db_reset: MethodRouter<S>,
14991556
/// GET: /database/: name_or_identity/unstable/timestamp
15001557
pub timestamp_get: MethodRouter<S>,
1558+
/// POST: /database/:name_or_identity/lock
1559+
pub lock_post: MethodRouter<S>,
1560+
/// POST: /database/:name_or_identity/unlock
1561+
pub unlock_post: MethodRouter<S>,
15011562
/// ANY: /database/:name_or_identity/route
15021563
pub http_route_root: MethodRouter<S>,
15031564
/// ANY: /database/:name_or_identity/route/
@@ -1529,6 +1590,8 @@ where
15291590
pre_publish: post(pre_publish::<S>),
15301591
db_reset: put(reset::<S>),
15311592
timestamp_get: get(get_timestamp::<S>),
1593+
lock_post: post(lock_database::<S>),
1594+
unlock_post: post(unlock_database::<S>),
15321595
http_route_root: any(handle_http_route_root::<S>),
15331596
http_route_root_slash: any(handle_http_route_root_slash::<S>),
15341597
http_route: any(handle_http_route::<S>),
@@ -1556,7 +1619,9 @@ where
15561619
.route("/sql", self.sql_post)
15571620
.route("/unstable/timestamp", self.timestamp_get)
15581621
.route("/pre_publish", self.pre_publish)
1559-
.route("/reset", self.db_reset);
1622+
.route("/reset", self.db_reset)
1623+
.route("/lock", self.lock_post)
1624+
.route("/unlock", self.unlock_post);
15601625

15611626
let authed_root_router = axum::Router::new().route(
15621627
"/",
@@ -1761,6 +1826,10 @@ mod tests {
17611826
async fn lookup_namespace_owner(&self, _name: &str) -> anyhow::Result<Option<Identity>> {
17621827
Ok(None)
17631828
}
1829+
1830+
async fn is_database_locked(&self, _database_identity: &Identity) -> anyhow::Result<bool> {
1831+
Ok(false)
1832+
}
17641833
}
17651834

17661835
#[async_trait]
@@ -1790,6 +1859,15 @@ mod tests {
17901859
Err(anyhow::anyhow!("unused"))
17911860
}
17921861

1862+
async fn set_database_lock(
1863+
&self,
1864+
_caller_identity: &Identity,
1865+
_database_identity: &Identity,
1866+
_locked: bool,
1867+
) -> anyhow::Result<()> {
1868+
Err(anyhow::anyhow!("unused"))
1869+
}
1870+
17931871
async fn reset_database(&self, _caller_identity: &Identity, _spec: DatabaseResetDef) -> anyhow::Result<()> {
17941872
Err(anyhow::anyhow!("unused"))
17951873
}

0 commit comments

Comments
 (0)