Skip to content

Commit 0c8ec49

Browse files
committed
teams: API authorization, CLI, smoketests
This adds authorization to the relevant API endpoints, updates the CLI commands and adds smoketests for the teams feature. Depends-on: #3519
1 parent 257e77c commit 0c8ec49

File tree

16 files changed

+717
-103
lines changed

16 files changed

+717
-103
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
run: python -m pip install psycopg2-binary xmltodict
7272
- name: Run smoketests
7373
# Note: clear_database and replication only work in private
74-
run: python -m smoketests ${{ matrix.smoketest_args }} -x clear_database replication
74+
run: python -m smoketests ${{ matrix.smoketest_args }} -x clear_database replication teams
7575
- name: Stop containers (Linux)
7676
if: always() && runner.os == 'Linux'
7777
run: docker compose down

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ tar = "0.4"
266266
tempdir = "0.3.7"
267267
tempfile = "3.20"
268268
termcolor = "1.2.0"
269+
termtree = "0.5.1"
269270
thin-vec = "0.2.13"
270271
thiserror = "1.0.37"
271272
tokio = { version = "1.37", features = ["full"] }

crates/cli/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ tabled.workspace = true
6464
tar.workspace = true
6565
tempfile.workspace = true
6666
termcolor.workspace = true
67+
termtree.workspace = true
6768
thiserror.workspace = true
6869
tokio.workspace = true
6970
tokio-tungstenite.workspace = true
@@ -77,6 +78,9 @@ clap-markdown.workspace = true
7778
rolldown.workspace = true
7879
rolldown_utils.workspace = true
7980

81+
[dev-dependencies]
82+
pretty_assertions.workspace = true
83+
8084
[target.'cfg(not(target_env = "msvc"))'.dependencies]
8185
tikv-jemallocator = { workspace = true }
8286
tikv-jemalloc-ctl = { workspace = true }

crates/cli/src/subcommands/delete.rs

Lines changed: 146 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
use std::io;
2+
13
use crate::common_args;
24
use crate::config::Config;
3-
use crate::util::{add_auth_header_opt, database_identity, get_auth_header};
5+
use crate::util::{add_auth_header_opt, database_identity, get_auth_header, y_or_n, AuthHeader};
46
use clap::{Arg, ArgMatches};
7+
use http::StatusCode;
8+
use itertools::Itertools as _;
9+
use reqwest::Response;
10+
use spacetimedb_client_api_messages::http::{DatabaseDeleteConfirmationResponse, DatabaseTree, DatabaseTreeNode};
11+
use spacetimedb_lib::Hash;
12+
use tokio::io::AsyncWriteExt as _;
513

614
pub fn cli() -> clap::Command {
715
clap::Command::new("delete")
@@ -22,11 +30,143 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
2230
let force = args.get_flag("force");
2331

2432
let identity = database_identity(&config, database, server).await?;
25-
26-
let builder = reqwest::Client::new().delete(format!("{}/v1/database/{}", config.get_host_url(server)?, identity));
33+
let host_url = config.get_host_url(server)?;
34+
let request_path = format!("{host_url}/v1/database/{identity}");
2735
let auth_header = get_auth_header(&mut config, false, server, !force).await?;
28-
let builder = add_auth_header_opt(builder, &auth_header);
29-
builder.send().await?.error_for_status()?;
36+
let client = reqwest::Client::new();
37+
38+
let response = send_request(&client, &request_path, &auth_header, None).await?;
39+
match response.status() {
40+
StatusCode::PRECONDITION_REQUIRED => {
41+
let confirm = response.json::<DatabaseDeleteConfirmationResponse>().await?;
42+
println!("WARNING: Deleting the database {identity} will also delete its children!");
43+
if !force {
44+
print_database_tree_info(&confirm.database_tree).await?;
45+
}
46+
if y_or_n(force, "Do you want to proceed deleting above databases?")? {
47+
send_request(&client, &request_path, &auth_header, Some(confirm.confirmation_token))
48+
.await?
49+
.error_for_status()?;
50+
} else {
51+
println!("Aborting");
52+
}
53+
54+
Ok(())
55+
}
56+
StatusCode::OK => Ok(()),
57+
_ => response.error_for_status().map(drop).map_err(Into::into),
58+
}
59+
}
60+
61+
async fn send_request(
62+
client: &reqwest::Client,
63+
request_path: &str,
64+
auth: &AuthHeader,
65+
confirmation_token: Option<Hash>,
66+
) -> Result<Response, reqwest::Error> {
67+
let mut builder = client.delete(request_path);
68+
builder = add_auth_header_opt(builder, auth);
69+
if let Some(token) = confirmation_token {
70+
builder = builder.query(&[("token", token)]);
71+
}
72+
builder.send().await
73+
}
74+
75+
async fn print_database_tree_info(tree: &DatabaseTree) -> io::Result<()> {
76+
tokio::io::stdout()
77+
.write_all(as_termtree(tree).to_string().as_bytes())
78+
.await
79+
}
80+
81+
fn as_termtree(tree: &DatabaseTree) -> termtree::Tree<String> {
82+
let mut stack: Vec<(&DatabaseTree, bool)> = vec![];
83+
stack.push((tree, false));
84+
85+
let mut built: Vec<termtree::Tree<String>> = <_>::default();
86+
87+
while let Some((node, visited)) = stack.pop() {
88+
if visited {
89+
let mut term_node = termtree::Tree::new(fmt_tree_node(&node.root));
90+
term_node.leaves = built.drain(built.len() - node.children.len()..).collect();
91+
term_node.leaves.reverse();
92+
built.push(term_node);
93+
} else {
94+
stack.push((node, true));
95+
stack.extend(node.children.iter().rev().map(|child| (child, false)));
96+
}
97+
}
98+
99+
built
100+
.pop()
101+
.expect("database tree contains a root and we pushed it last")
102+
}
103+
104+
fn fmt_tree_node(node: &DatabaseTreeNode) -> String {
105+
format!(
106+
"{}{}",
107+
node.database_identity,
108+
if node.database_names.is_empty() {
109+
<_>::default()
110+
} else {
111+
format!(": {}", node.database_names.iter().join(", "))
112+
}
113+
)
114+
}
115+
116+
#[cfg(test)]
117+
mod tests {
118+
use super::*;
119+
use spacetimedb_client_api_messages::http::{DatabaseTree, DatabaseTreeNode};
120+
use spacetimedb_lib::{sats::u256, Identity};
30121

31-
Ok(())
122+
#[test]
123+
fn render_termtree() {
124+
let tree = DatabaseTree {
125+
root: DatabaseTreeNode {
126+
database_identity: Identity::ONE,
127+
database_names: ["parent".into()].into(),
128+
},
129+
children: vec![
130+
DatabaseTree {
131+
root: DatabaseTreeNode {
132+
database_identity: Identity::from_u256(u256::new(2)),
133+
database_names: ["child".into()].into(),
134+
},
135+
children: vec![
136+
DatabaseTree {
137+
root: DatabaseTreeNode {
138+
database_identity: Identity::from_u256(u256::new(3)),
139+
database_names: ["grandchild".into()].into(),
140+
},
141+
children: vec![],
142+
},
143+
DatabaseTree {
144+
root: DatabaseTreeNode {
145+
database_identity: Identity::from_u256(u256::new(5)),
146+
database_names: [].into(),
147+
},
148+
children: vec![],
149+
},
150+
],
151+
},
152+
DatabaseTree {
153+
root: DatabaseTreeNode {
154+
database_identity: Identity::from_u256(u256::new(4)),
155+
database_names: ["sibling".into(), "bro".into()].into(),
156+
},
157+
children: vec![],
158+
},
159+
],
160+
};
161+
pretty_assertions::assert_eq!(
162+
"\
163+
0000000000000000000000000000000000000000000000000000000000000001: parent
164+
├── 0000000000000000000000000000000000000000000000000000000000000004: bro, sibling
165+
└── 0000000000000000000000000000000000000000000000000000000000000002: child
166+
├── 0000000000000000000000000000000000000000000000000000000000000005
167+
└── 0000000000000000000000000000000000000000000000000000000000000003: grandchild
168+
",
169+
&as_termtree(&tree).to_string()
170+
);
171+
}
32172
}

crates/cli/src/subcommands/publish.rs

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::path::PathBuf;
88
use std::{env, fs};
99

1010
use crate::config::Config;
11-
use crate::util::{add_auth_header_opt, get_auth_header, unauth_error_context, AuthHeader, ResponseExt};
11+
use crate::util::{add_auth_header_opt, get_auth_header, AuthHeader, ResponseExt};
1212
use crate::util::{decode_identity, y_or_n};
1313
use crate::{build, common_args};
1414

@@ -220,18 +220,6 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
220220
}
221221

222222
let res = builder.body(program_bytes).send().await?;
223-
if res.status() == StatusCode::UNAUTHORIZED && !anon_identity {
224-
// 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.
225-
let token = config.spacetimedb_token().unwrap();
226-
let identity = decode_identity(token)?;
227-
let err = res.text().await?;
228-
return unauth_error_context(
229-
Err(anyhow::anyhow!(err)),
230-
&identity,
231-
config.server_nick_or_host(server)?,
232-
);
233-
}
234-
235223
let response: PublishResult = res.json_or_error().await?;
236224
match response {
237225
PublishResult::Success {

crates/cli/src/util.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -281,15 +281,6 @@ pub fn y_or_n(force: bool, prompt: &str) -> anyhow::Result<bool> {
281281
Ok(input == "y" || input == "yes")
282282
}
283283

284-
pub fn unauth_error_context<T>(res: anyhow::Result<T>, identity: &str, server: &str) -> anyhow::Result<T> {
285-
res.with_context(|| {
286-
format!(
287-
"Identity {identity} is not valid for server {server}.
288-
Please log back in with `spacetime logout` and then `spacetime login`."
289-
)
290-
})
291-
}
292-
293284
pub fn decode_identity(token: &String) -> anyhow::Result<String> {
294285
// Here, we manually extract and decode the claims from the json web token.
295286
// We do this without using the `jsonwebtoken` crate because it doesn't seem to have a way to skip signature verification.

crates/client-api-messages/src/http.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
use std::collections::BTreeSet;
2+
use std::iter;
3+
14
use serde::{Deserialize, Serialize};
25
use spacetimedb_lib::metrics::ExecutionMetrics;
3-
use spacetimedb_lib::ProductType;
6+
use spacetimedb_lib::{Hash, Identity, ProductType};
47

58
#[derive(Debug, Clone, Serialize, Deserialize)]
69
pub struct SqlStmtResult<Row> {
@@ -27,3 +30,34 @@ impl SqlStmtStats {
2730
}
2831
}
2932
}
33+
34+
#[derive(Clone, Debug, Serialize, Deserialize)]
35+
pub struct DatabaseTree {
36+
pub root: DatabaseTreeNode,
37+
pub children: Vec<DatabaseTree>,
38+
}
39+
40+
impl DatabaseTree {
41+
pub fn iter(&self) -> impl Iterator<Item = &DatabaseTreeNode> + '_ {
42+
let mut stack = vec![self];
43+
iter::from_fn(move || {
44+
let node = stack.pop()?;
45+
for child in node.children.iter().rev() {
46+
stack.push(child);
47+
}
48+
Some(&node.root)
49+
})
50+
}
51+
}
52+
53+
#[derive(Clone, Debug, Serialize, Deserialize)]
54+
pub struct DatabaseTreeNode {
55+
pub database_identity: Identity,
56+
pub database_names: BTreeSet<String>,
57+
}
58+
59+
#[derive(Debug, Serialize, Deserialize)]
60+
pub struct DatabaseDeleteConfirmationResponse {
61+
pub database_tree: DatabaseTree,
62+
pub confirmation_token: Hash,
63+
}

crates/client-api/src/lib.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
use std::future::Future;
12
use std::num::NonZeroU8;
23
use std::sync::Arc;
34

5+
use anyhow::anyhow;
46
use async_trait::async_trait;
57
use axum::response::ErrorResponse;
68
use bytes::Bytes;
@@ -17,6 +19,7 @@ use spacetimedb_client_api_messages::name::{DomainName, InsertDomainResult, Regi
1719
use spacetimedb_lib::{ProductTypeElement, ProductValue};
1820
use spacetimedb_paths::server::ModuleLogsDir;
1921
use spacetimedb_schema::auto_migrate::{MigrationPolicy, PrettyPrintStyle};
22+
use thiserror::Error;
2023
use tokio::sync::watch;
2124

2225
pub mod auth;
@@ -413,6 +416,60 @@ impl<T: NodeDelegate + ?Sized> NodeDelegate for Arc<T> {
413416
}
414417
}
415418

419+
#[derive(Debug, Error)]
420+
pub enum Unauthorized {
421+
#[error("{subject} is not authorized to perform {action:?}")]
422+
Unauthorized {
423+
subject: Identity,
424+
action: Action,
425+
#[source]
426+
source: Option<anyhow::Error>,
427+
},
428+
#[error("authorization failed due to internal error")]
429+
InternalError(#[from] anyhow::Error),
430+
}
431+
432+
impl Unauthorized {
433+
pub fn into_response(self) -> ErrorResponse {
434+
match self {
435+
unauthorized @ Self::Unauthorized { .. } => {
436+
(StatusCode::UNAUTHORIZED, format!("{:#}", anyhow!(unauthorized))).into()
437+
}
438+
Self::InternalError(e) => log_and_500(e),
439+
}
440+
}
441+
}
442+
443+
#[derive(Debug)]
444+
pub enum Action {
445+
CreateDatabase { parent: Option<Identity> },
446+
UpdateDatabase,
447+
ResetDatabase,
448+
DeleteDatabase,
449+
RenameDatabase,
450+
ViewModuleLogs,
451+
}
452+
453+
pub trait Authorization {
454+
fn authorize_action(
455+
&self,
456+
subject: Identity,
457+
database: Identity,
458+
action: Action,
459+
) -> impl Future<Output = Result<(), Unauthorized>> + Send;
460+
}
461+
462+
impl<T: Authorization> Authorization for Arc<T> {
463+
fn authorize_action(
464+
&self,
465+
subject: Identity,
466+
database: Identity,
467+
action: Action,
468+
) -> impl Future<Output = Result<(), Unauthorized>> + Send {
469+
(**self).authorize_action(subject, database, action)
470+
}
471+
}
472+
416473
pub fn log_and_500(e: impl std::fmt::Display) -> ErrorResponse {
417474
log::error!("internal error: {e:#}");
418475
(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into()

0 commit comments

Comments
 (0)