Allow authenticating with a token from the CLI (#4362)

This commit is contained in:
Gerard Guillemas Martos 2024-07-15 17:57:28 +02:00 committed by GitHub
parent a81aef0e93
commit 84d289d868
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 375 additions and 0 deletions

2
Cargo.lock generated
View file

@ -5873,6 +5873,7 @@ dependencies = [
"axum-server",
"base64 0.21.7",
"bytes",
"chrono",
"ciborium",
"clap",
"env_logger 0.10.2",
@ -5905,6 +5906,7 @@ dependencies = [
"serde_json",
"serial_test",
"surrealdb",
"surrealdb-jsonwebtoken",
"temp-env",
"tempfile",
"test-log",

View file

@ -132,7 +132,9 @@ jemallocator = "0.5.4"
[dev-dependencies]
assert_fs = "1.0.13"
chrono = { version = "0.4.31", features = ["serde"] }
env_logger = "0.10.1"
jsonwebtoken = { version = "8.3.0-surreal.1", package = "surrealdb-jsonwebtoken" }
opentelemetry-proto = { version = "0.2.0", features = [
"gen-tonic",
"traces",

View file

@ -23,6 +23,14 @@ pub(crate) struct AuthArguments {
requires = "username"
)]
pub(crate) password: Option<String>,
#[arg(help = "Authentication token in JWT format to use when connecting")]
#[arg(
env = "SURREAL_TOKEN",
short = 't',
long = "token",
conflicts_with_all = ["username", "password", "auth_level"],
)]
pub(crate) token: Option<String>,
#[arg(help = "Authentication level to use when connecting")]
#[arg(env = "SURREAL_AUTH_LEVEL", long = "auth-level", default_value = "root")]
#[arg(value_parser = super::validator::parser::creds_level::CredentialsLevelParser::new())]

View file

@ -32,6 +32,7 @@ pub async fn init(
auth: AuthArguments {
username,
password,
token,
auth_level,
},
sel: DatabaseSelectionArguments {
@ -65,6 +66,11 @@ pub async fn init(
CredentialsLevel::Database => client.signin(creds.database()?).await?,
};
client
} else if token.is_some() && !endpoint.clone().into_endpoint()?.parse_kind()?.is_local() {
let client = connect(endpoint).await?;
client.authenticate(token.unwrap()).await?;
client
} else {
debug!("Connecting to the database engine without authentication");

View file

@ -29,6 +29,7 @@ pub async fn init(
auth: AuthArguments {
username,
password,
token,
auth_level,
},
sel: DatabaseSelectionArguments {
@ -64,6 +65,11 @@ pub async fn init(
CredentialsLevel::Database => client.signin(creds.database()?).await?,
};
client
} else if token.is_some() && !endpoint.clone().into_endpoint()?.parse_kind()?.is_local() {
let client = connect(endpoint).await?;
client.authenticate(token.unwrap()).await?;
client
} else {
debug!("Connecting to the database engine without authentication");

View file

@ -48,6 +48,7 @@ pub async fn init(
auth: AuthArguments {
username,
password,
token,
auth_level,
},
sel: DatabaseSelectionArguments {
@ -81,6 +82,11 @@ pub async fn init(
CredentialsLevel::Database => client.signin(creds.database()?).await?,
};
client
} else if token.is_some() && !endpoint.clone().into_endpoint()?.parse_kind()?.is_local() {
let client = connect(endpoint).await?;
client.authenticate(token.unwrap()).await?;
client
} else {
debug!("Connecting to the database engine without authentication");

View file

@ -29,6 +29,7 @@ pub async fn init(
auth: AuthArguments {
username,
password,
token,
auth_level,
},
sel: DatabaseSelectionArguments {
@ -64,6 +65,11 @@ pub async fn init(
CredentialsLevel::Database => client.signin(creds.database()?).await?,
};
client
} else if token.is_some() && !endpoint.clone().into_endpoint()?.parse_kind()?.is_local() {
let client = connect(endpoint).await?;
client.authenticate(token.unwrap()).await?;
client
} else {
debug!("Connecting to the database engine without authentication");

View file

@ -44,6 +44,7 @@ pub async fn init(
auth: AuthArguments {
username,
password,
token,
auth_level,
},
conn: DatabaseConnectionArguments {
@ -87,6 +88,11 @@ pub async fn init(
CredentialsLevel::Database => client.signin(creds.database()?).await?,
};
client
} else if token.is_some() && !endpoint.clone().into_endpoint()?.parse_kind()?.is_local() {
let client = connect(endpoint).await?;
client.authenticate(token.unwrap()).await?;
client
} else {
debug!("Connecting to the database engine without authentication");

View file

@ -4,8 +4,12 @@ mod common;
mod cli_integration {
use crate::remove_debug_info;
use assert_fs::prelude::{FileTouch, FileWriteStr, PathChild};
use chrono::Duration as ChronoDuration;
use chrono::Utc;
use common::Format;
use common::Socket;
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fs::File;
use std::time;
@ -488,6 +492,335 @@ mod cli_integration {
server.finish().unwrap();
}
#[test(tokio::test)]
async fn with_auth_level_token() {
// Commands with token for different auth levels
let (addr, mut server) = common::start_server_with_defaults().await.unwrap();
let ac = "test";
let ns = Ulid::new();
let db = Ulid::new();
let exp = (Utc::now() + ChronoDuration::days(1)).timestamp();
let key = "secret";
let header = Header::new(Algorithm::HS512);
let key_enc = EncodingKey::from_secret(key.as_ref());
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
exp: Option<i64>,
iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
ns: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
db: Option<String>,
ac: Option<String>,
rl: Option<Vec<String>>,
}
let claims_root = serde_json::from_str::<Claims>(
format!(
r#"
{{
"iss": "surrealdb-test",
"exp": {exp},
"ac": "{ac}",
"rl": ["Owner"]
}}
"#
)
.as_ref(),
)
.unwrap();
let token_root = match encode(&header, &claims_root, &key_enc) {
Ok(enc) => enc,
Err(err) => panic!("Failed to encode token: {:?}", err),
};
let claims_ns = serde_json::from_str::<Claims>(
format!(
r#"
{{
"iss": "surrealdb-test",
"exp": {exp},
"ac": "{ac}",
"ns": "{ns}",
"rl": ["Owner"]
}}
"#
)
.as_ref(),
)
.unwrap();
let token_ns = match encode(&header, &claims_ns, &key_enc) {
Ok(enc) => enc,
Err(err) => panic!("Failed to encode token: {:?}", err),
};
let claims_db = serde_json::from_str::<Claims>(
format!(
r#"
{{
"iss": "surrealdb-test",
"exp": {exp},
"ac": "{ac}",
"ns": "{ns}",
"db": "{db}",
"rl": ["Owner"]
}}
"#
)
.as_ref(),
)
.unwrap();
let token_db = match encode(&header, &claims_db, &key_enc) {
Ok(enc) => enc,
Err(err) => panic!("Failed to encode token: {:?}", err),
};
let record_user = "user:1";
let claims_record = serde_json::from_str::<Claims>(
format!(
r#"
{{
"iss": "surrealdb-test",
"exp": {exp},
"ac": "{ac}",
"ns": "{ns}",
"db": "{db}",
"id": "{record_user}"
}}
"#
)
.as_ref(),
)
.unwrap();
let token_record = match encode(&header, &claims_record, &key_enc) {
Ok(enc) => enc,
Err(err) => panic!("Failed to encode token: {:?}", err),
};
info!("* Create access methods with identical names at ROOT, NS and DB levels");
{
let args =
format!("sql --conn http://{addr} --db {db} --ns {ns} --user {USER} --pass {PASS}");
let _ = common::run(&args)
.input(format!("DEFINE ACCESS {ac} ON ROOT TYPE JWT ALGORITHM HS512 KEY '{key}';
DEFINE ACCESS {ac} ON NAMESPACE TYPE JWT ALGORITHM HS512 KEY '{key}';
DEFINE ACCESS {ac} ON DATABASE TYPE JWT ALGORITHM HS512 KEY '{key}';\n").as_str())
.output()
.expect("success");
}
info!("* Create record that will be used as record user for authentication");
{
let args =
format!("sql --conn http://{addr} --db {db} --ns {ns} --user {USER} --pass {PASS}");
let _ = common::run(&args)
.input(format!("CREATE {record_user};").as_str())
.output()
.expect("success");
}
info!("* Pass root token and access root info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_root}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR ROOT;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("namespaces: {"),
"root token should be able to access root info: {output}"
);
}
info!("* Pass root token and access namespace info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_root}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR NS;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("databases: {"),
"root token should be able to access namespace info: {output}"
);
}
info!("* Pass root auth level and access database info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_root}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR DB;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("tables: {"),
"root token should be able to access database info: {output}"
);
}
info!("* Pass namespace token and access root info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_ns}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR ROOT;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("IAM error: Not enough permissions to perform this action"),
"namespace token should not be able to access root info: {output}"
);
}
info!("* Pass namespace token and access namespace info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_ns}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR NS;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("databases: {"),
"namespace token should be able to access namespace info: {output}"
);
}
info!("* Pass namespace token and access database info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_ns}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR DB;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("tables: {"),
"namespace token should be able to access database info: {output}"
);
}
info!("* Pass database token and access root info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_db}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR ROOT;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("IAM error: Not enough permissions to perform this action"),
"database token should not be able to access root info: {output}",
);
}
info!("* Pass database token and access namespace info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_db}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR NS;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("IAM error: Not enough permissions to perform this action"),
"database token should not be able to access namespace info: {output}",
);
}
info!("* Pass database token and access database info");
{
let args = format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_db}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR DB;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("tables: {"),
"database token should be able to access database info: {output}"
);
}
info!("* Pass database token without specifying namespace or database");
{
let args = format!("sql --conn http://{addr} --token {token_db}");
let output = common::run(&args).input("INFO FOR DB;\n").output().expect("success");
assert!(
output.contains("tables: {"),
"namespace and database to use should be selected from token: {output}"
);
}
info!("* Pass record user token and access database info");
{
let args =
format!("sql --conn http://{addr} --db {db} --ns {ns} --token {token_record}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR DB;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("tables: {"),
"database token should be able to access database info: {output}"
);
}
info!("* Pass namespace token without specifying namespace");
{
let args = format!("sql --conn http://{addr} --token {token_ns}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR NS;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("databases: {"),
"namespace should be selected from token: {output}"
);
}
info!("* Pass database token without specifying database");
{
let args = format!("sql --conn http://{addr} --ns {ns} --token {token_db}");
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR DB;\n").as_str())
.output()
.expect("success");
assert!(
output.contains("tables: {"),
"database should be selected from token: {output}"
);
}
info!("* Pass token at the same time as credentials");
{
let args = format!(
"sql --conn http://{addr} --ns {ns} --token {token_db} -u {USER} -p {PASS}"
);
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR DB;\n").as_str())
.output();
assert!(
output
.clone()
.unwrap_err()
.contains("error: the argument '--token <TOKEN>' cannot be used with"),
"the token argument should not be compatible with credentials: {:?}",
output
);
}
info!("* Pass token at the same time as different auth level");
{
let args = format!(
"sql --conn http://{addr} --ns {ns} --token {token_db} --auth-level namespace"
);
let output = common::run(&args)
.input(format!("USE NS `{ns}` DB `{db}`; INFO FOR DB;\n").as_str())
.output();
assert!(
output
.clone()
.unwrap_err()
.contains("error: the argument '--token <TOKEN>' cannot be used with"),
"the token argument should not be compatible with authentication level: {:?}",
output
);
}
server.finish().unwrap();
}
#[test(tokio::test)]
async fn with_anon_auth() {
// Commands without credentials when auth is enabled, should fail