diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d03d685..056e59a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,3 +43,5 @@ jobs: run: tests/set_up_e2e_env.sh - name: Run tests run: cargo test --verbose + env: + RUST_BACKTRACE: 1 diff --git a/src/ayb_db/db_interfaces.rs b/src/ayb_db/db_interfaces.rs index 98bb836..1b5ce55 100644 --- a/src/ayb_db/db_interfaces.rs +++ b/src/ayb_db/db_interfaces.rs @@ -1,6 +1,7 @@ use crate::ayb_db::models::{ - APIToken, AuthenticationMethod, Database, Entity, InstantiatedAuthenticationMethod, - InstantiatedDatabase, InstantiatedEntity, PartialDatabase, PartialEntity, + APIToken, AuthenticationMethod, Database, Entity, EntityDatabasePermission, + InstantiatedAuthenticationMethod, InstantiatedDatabase, InstantiatedEntity, PartialDatabase, + PartialEntity, }; use crate::error::AybError; use async_trait::async_trait; @@ -30,6 +31,11 @@ pub trait AybDb: DynClone + Send + Sync { method: &AuthenticationMethod, ) -> Result; async fn create_database(&self, database: &Database) -> Result; + async fn delete_entity_database_permission( + &self, + entity_id: i32, + database_id: i32, + ) -> Result<(), AybError>; async fn get_or_create_entity(&self, entity: &Entity) -> Result; async fn get_api_token(&self, short_token: &str) -> Result; async fn get_database( @@ -39,6 +45,11 @@ pub trait AybDb: DynClone + Send + Sync { ) -> Result; async fn get_entity_by_slug(&self, entity_slug: &str) -> Result; async fn get_entity_by_id(&self, entity_id: i32) -> Result; + async fn get_entity_database_permission( + &self, + entity: &InstantiatedEntity, + database: &InstantiatedDatabase, + ) -> Result, AybError>; async fn update_database_by_id( &self, database_id: i32, @@ -49,6 +60,10 @@ pub trait AybDb: DynClone + Send + Sync { entity_id: i32, entity: &PartialEntity, ) -> Result; + async fn update_or_create_entity_database_permission( + &self, + permission: &EntityDatabasePermission, + ) -> Result<(), AybError>; async fn list_authentication_methods( &self, entity: &InstantiatedEntity, @@ -146,6 +161,25 @@ RETURNING entity_id, short_token, hash, status Ok(db) } + async fn delete_entity_database_permission( + &self, + entity_id: i32, + database_id: i32, + ) -> Result<(), AybError> { + sqlx::query( + r#" +DELETE FROM entity_database_permission +WHERE entity_id = $1 AND database_id = $2; + "#, + ) + .bind(entity_id) + .bind(database_id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_api_token( &self, short_token: &str, @@ -276,6 +310,26 @@ WHERE id = $1 Ok(entity) } + async fn get_entity_database_permission(&self, entity: &InstantiatedEntity, database: &InstantiatedDatabase) -> Result, AybError> { + let permission: Option = sqlx::query_as( + r#" +SELECT + entity_id, + database_id, + sharing_level +FROM entity_database_permission +WHERE entity_id = $1 + AND database_id = $2 + "#, + ) + .bind(entity.id) + .bind(database.id) + .fetch_optional(&self.pool) + .await?; + + Ok(permission) + } + async fn update_database_by_id(&self, database_id: i32, database: &PartialDatabase) -> Result { let mut query = QueryBuilder::new("UPDATE database SET"); let mut updated_field = false; @@ -372,6 +426,28 @@ WHERE id = $1 Ok(entity) } + async fn update_or_create_entity_database_permission( + &self, + permission: &EntityDatabasePermission, + ) -> Result<(), AybError> { + sqlx::query( + r#" +INSERT INTO entity_database_permission (entity_id, database_id, sharing_level) +VALUES ($1, $2, $3) +ON CONFLICT (entity_id, database_id) DO UPDATE + SET sharing_level = $3 + "#, + ) + .bind(permission.entity_id) + .bind(permission.database_id) + .bind(permission.sharing_level) + .execute(&self.pool) + .await?; + Ok(()) + } + + + async fn get_or_create_entity(&self, entity: &Entity) -> Result { // Get or create logic inspired by https://stackoverflow.com/a/66337293 let mut tx = self.pool.begin().await?; @@ -439,6 +515,7 @@ SELECT public_sharing_level FROM database WHERE database.entity_id = $1 +ORDER BY id DESC "#, ) .bind(entity.id) diff --git a/src/ayb_db/models.rs b/src/ayb_db/models.rs index 133591a..be601df 100644 --- a/src/ayb_db/models.rs +++ b/src/ayb_db/models.rs @@ -287,26 +287,30 @@ pub struct APIToken { )] #[repr(i16)] pub enum EntityDatabaseSharingLevel { - ReadOnly = 0, - ReadWrite = 1, - Manager = 2, + NoAccess = 0, + ReadOnly = 1, + ReadWrite = 2, + Manager = 3, } from_str!(EntityDatabaseSharingLevel, { + "no-access" => EntityDatabaseSharingLevel::NoAccess, "read-only" => EntityDatabaseSharingLevel::ReadOnly, "read-write" => EntityDatabaseSharingLevel::ReadWrite, "manager" => EntityDatabaseSharingLevel::Manager }); try_from_i16!(EntityDatabaseSharingLevel, { - 0 => EntityDatabaseSharingLevel::ReadOnly, - 1 => EntityDatabaseSharingLevel::ReadWrite, - 2 => EntityDatabaseSharingLevel::Manager + 0 => EntityDatabaseSharingLevel::NoAccess, + 1 => EntityDatabaseSharingLevel::ReadOnly, + 2 => EntityDatabaseSharingLevel::ReadWrite, + 3 => EntityDatabaseSharingLevel::Manager }); impl EntityDatabaseSharingLevel { pub fn to_str(&self) -> &str { match self { + EntityDatabaseSharingLevel::NoAccess => "no-access", EntityDatabaseSharingLevel::ReadOnly => "read-only", EntityDatabaseSharingLevel::ReadWrite => "read-write", EntityDatabaseSharingLevel::Manager => "manager", diff --git a/src/client/cli.rs b/src/client/cli.rs index c089e35..2d0a841 100644 --- a/src/client/cli.rs +++ b/src/client/cli.rs @@ -1,4 +1,4 @@ -use crate::ayb_db::models::{DBType, EntityType, PublicSharingLevel}; +use crate::ayb_db::models::{DBType, EntityDatabaseSharingLevel, EntityType, PublicSharingLevel}; use crate::client::config::ClientConfig; use crate::client::http::AybClient; use crate::error::AybError; @@ -185,9 +185,10 @@ pub fn client_commands() -> Command { .about("Update properties of a database") .arg(arg!( "The database to which to connect (e.g., entity/database.sqlite)") .value_parser(ValueParser::new(entity_database_parser)) - .required(true) // As we add other updateable properties, this one won't be required anymore. + .required(true) ) - .arg(arg!(--public_sharing_level "The level of public access to enable for this database").value_parser(value_parser!(PublicSharingLevel)).required(false)) + // As we add other updateable properties, this one won't be required anymore. + .arg(arg!(--public_sharing_level "The level of public access to enable for this database").value_parser(value_parser!(PublicSharingLevel)).required(true)) ) .subcommand( Command::new("set_default_url") @@ -195,6 +196,17 @@ pub fn client_commands() -> Command { .arg(arg!( "The URL to use in the future") .required(true)) ) + .subcommand( + Command::new("share") + .about("Share a database with another entity") + .arg(arg!( "The database to share (e.g., entity/database.sqlite)") + .value_parser(ValueParser::new(entity_database_parser)) + .required(true) + ) + .arg(arg!( "The entity with which to share") + .required(true)) + .arg(arg!( "The level of access for this entity").value_parser(value_parser!(EntityDatabaseSharingLevel)).required(true)) + ) .subcommand( Command::new("list_snapshots") .about("List snapshots/backups of a database") @@ -538,6 +550,32 @@ pub async fn execute_client_command(matches: &ArgMatches) -> std::io::Result<()> } } } + } else if let Some(matches) = matches.subcommand_matches("share") { + if let (Some(entity_database), Some(entity), Some(sharing_level)) = ( + matches.get_one::("database"), + matches.get_one::("entity"), + matches.get_one::("sharing_level"), + ) { + match client + .share( + &entity_database.entity, + &entity_database.database, + entity, + sharing_level, + ) + .await + { + Ok(_response) => { + println!( + "Permissions for {} on {}/{} updated successfully", + entity, entity_database.entity, entity_database.database + ); + } + Err(err) => { + println!("Error: {}", err); + } + } + } } Ok(()) diff --git a/src/client/http.rs b/src/client/http.rs index e8c54e6..7fdca0a 100644 --- a/src/client/http.rs +++ b/src/client/http.rs @@ -1,4 +1,4 @@ -use crate::ayb_db::models::{DBType, EntityType, PublicSharingLevel}; +use crate::ayb_db::models::{DBType, EntityDatabaseSharingLevel, EntityType, PublicSharingLevel}; use crate::error::AybError; use crate::hosted_db::QueryResult; use crate::http::structs::{APIToken, Database, EmptyResponse, EntityQueryResponse, SnapshotList}; @@ -290,4 +290,34 @@ impl AybClient { self.handle_empty_response(response, reqwest::StatusCode::OK) .await } + + pub async fn share( + &self, + entity_for_database: &str, + database: &str, + entity_for_permission: &str, + sharing_level: &EntityDatabaseSharingLevel, + ) -> Result<(), AybError> { + let mut headers = HeaderMap::new(); + self.add_bearer_token(&mut headers)?; + + headers.insert( + HeaderName::from_static("entity-for-permission"), + HeaderValue::from_str(entity_for_permission).unwrap(), + ); + + headers.insert( + HeaderName::from_static("sharing-level"), + HeaderValue::from_str(sharing_level.to_str()).unwrap(), + ); + + let response = reqwest::Client::new() + .post(self.make_url(format!("{}/{}/share", entity_for_database, database))) + .headers(headers) + .send() + .await?; + + self.handle_empty_response(response, reqwest::StatusCode::NO_CONTENT) + .await + } } diff --git a/src/error.rs b/src/error.rs index 8e7fcab..96fe25c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -18,6 +18,7 @@ use url; #[derive(Debug, Deserialize, Error, Serialize)] #[serde(tag = "type")] pub enum AybError { + CantSetOwnerPermissions { message: String }, DurationParseError { message: String }, NoWriteAccessError { message: String }, S3ExecutionError { message: String }, @@ -32,6 +33,7 @@ impl Display for AybError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { AybError::Other { message } => write!(f, "{}", message), + AybError::CantSetOwnerPermissions { message } => write!(f, "{}", message), AybError::NoWriteAccessError { message } => write!(f, "{}", message), _ => write!(f, "{:?}", self), } diff --git a/src/server/endpoints/entity_details.rs b/src/server/endpoints/entity_details.rs index c6ff0d6..b2e30ea 100644 --- a/src/server/endpoints/entity_details.rs +++ b/src/server/endpoints/entity_details.rs @@ -18,7 +18,7 @@ pub async fn entity_details( let mut databases = Vec::new(); for database in ayb_db.list_databases_by_entity(&desired_entity).await? { - if can_discover_database(&authenticated_entity, &database)? { + if can_discover_database(&authenticated_entity, &database, &ayb_db).await? { databases.push(database.into()); } } diff --git a/src/server/endpoints/list_snapshots.rs b/src/server/endpoints/list_snapshots.rs index 78491b7..842c7c3 100644 --- a/src/server/endpoints/list_snapshots.rs +++ b/src/server/endpoints/list_snapshots.rs @@ -4,7 +4,7 @@ use crate::ayb_db::models::InstantiatedEntity; use crate::error::AybError; use crate::http::structs::{EntityDatabasePath, SnapshotList}; use crate::server::config::AybConfig; -use crate::server::permissions::can_manage_snapshots; +use crate::server::permissions::can_manage_database; use crate::server::snapshots::models::ListSnapshotResult; use crate::server::snapshots::storage::SnapshotStorage; use crate::server::utils::unwrap_authenticated_entity; @@ -22,7 +22,7 @@ async fn list_snapshots( let database = ayb_db.get_database(entity_slug, database_slug).await?; let authenticated_entity = unwrap_authenticated_entity(&authenticated_entity)?; - if can_manage_snapshots(&authenticated_entity, &database) { + if can_manage_database(&authenticated_entity, &database, &ayb_db).await? { let mut recent_snapshots: Vec = Vec::new(); if let Some(ref snapshot_config) = ayb_config.snapshots { let snapshot_storage = SnapshotStorage::new(snapshot_config).await?; diff --git a/src/server/endpoints/mod.rs b/src/server/endpoints/mod.rs index 919f375..95e9902 100644 --- a/src/server/endpoints/mod.rs +++ b/src/server/endpoints/mod.rs @@ -6,6 +6,7 @@ mod log_in; mod query; mod register; mod restore_snapshot; +mod share; mod update_database; mod update_profile; @@ -17,5 +18,6 @@ pub use log_in::log_in as log_in_endpoint; pub use query::query as query_endpoint; pub use register::register as register_endpoint; pub use restore_snapshot::restore_snapshot as restore_snapshot_endpoint; +pub use share::share as share_endpoint; pub use update_database::update_database as update_db_endpoint; pub use update_profile::update_profile as update_profile_endpoint; diff --git a/src/server/endpoints/query.rs b/src/server/endpoints/query.rs index b5a8a5d..05af190 100644 --- a/src/server/endpoints/query.rs +++ b/src/server/endpoints/query.rs @@ -23,7 +23,8 @@ async fn query( let database = ayb_db.get_database(entity_slug, database_slug).await?; let authenticated_entity = unwrap_authenticated_entity(&authenticated_entity)?; - let access_level = highest_query_access_level(&authenticated_entity, &database)?; + let access_level = + highest_query_access_level(&authenticated_entity, &database, &ayb_db).await?; match access_level { Some(access_level) => { let db_type = DBType::try_from(database.db_type)?; diff --git a/src/server/endpoints/restore_snapshot.rs b/src/server/endpoints/restore_snapshot.rs index c761198..7f74f85 100644 --- a/src/server/endpoints/restore_snapshot.rs +++ b/src/server/endpoints/restore_snapshot.rs @@ -4,7 +4,7 @@ use crate::error::AybError; use crate::hosted_db::paths::{new_database_path, set_current_database_and_clean_up}; use crate::http::structs::{EmptyResponse, EntityDatabasePath}; use crate::server::config::AybConfig; -use crate::server::permissions::can_manage_snapshots; +use crate::server::permissions::can_manage_database; use crate::server::snapshots::storage::SnapshotStorage; use crate::server::utils::unwrap_authenticated_entity; use actix_web::{post, web, HttpResponse}; @@ -22,7 +22,7 @@ async fn restore_snapshot( let database = ayb_db.get_database(entity_slug, database_slug).await?; let authenticated_entity = unwrap_authenticated_entity(&authenticated_entity)?; - if can_manage_snapshots(&authenticated_entity, &database) { + if can_manage_database(&authenticated_entity, &database, &ayb_db).await? { if let Some(ref snapshot_config) = ayb_config.snapshots { // TODO(marcua): In the future, consider quiescing // requests to this database during the process, and diff --git a/src/server/endpoints/share.rs b/src/server/endpoints/share.rs new file mode 100644 index 0000000..93d63d0 --- /dev/null +++ b/src/server/endpoints/share.rs @@ -0,0 +1,63 @@ +use crate::ayb_db::db_interfaces::AybDb; +use crate::ayb_db::models::{ + EntityDatabasePermission, EntityDatabaseSharingLevel, InstantiatedEntity, +}; +use std::str::FromStr; + +use crate::error::AybError; +use crate::http::structs::EntityDatabasePath; +use crate::server::permissions::can_manage_database; +use crate::server::utils::{get_required_header, unwrap_authenticated_entity}; +use actix_web::{post, web, HttpRequest, HttpResponse}; + +#[post("/v1/{entity}/{database}/share")] +async fn share( + path: web::Path, + req: HttpRequest, + ayb_db: web::Data>, + authenticated_entity: Option>, +) -> Result { + let entity_for_database_slug = &path.entity.to_lowercase(); + let database_slug = &path.database; + let database = ayb_db + .get_database(entity_for_database_slug, database_slug) + .await?; + let sharing_level = + EntityDatabaseSharingLevel::from_str(&get_required_header(&req, "sharing-level")?)?; + let entity_for_permission = ayb_db + .get_entity_by_slug(&get_required_header(&req, "entity-for-permission")?) + .await?; + let authenticated_entity = unwrap_authenticated_entity(&authenticated_entity)?; + if entity_for_permission.id == database.entity_id { + Err(AybError::CantSetOwnerPermissions { + message: format!( + "{} owns {}/{}, so their permissions can't be changed", + entity_for_permission.slug, entity_for_database_slug, database_slug + ), + }) + } else if can_manage_database(&authenticated_entity, &database, &ayb_db).await? { + if sharing_level == EntityDatabaseSharingLevel::NoAccess { + ayb_db + .delete_entity_database_permission(entity_for_permission.id, database.id) + .await?; + } else { + let permission = EntityDatabasePermission { + entity_id: entity_for_permission.id, + database_id: database.id, + sharing_level: sharing_level as i16, + }; + ayb_db + .update_or_create_entity_database_permission(&permission) + .await?; + } + + Ok(HttpResponse::NoContent().into()) + } else { + Err(AybError::Other { + message: format!( + "Authenticated entity {} can't set permissions for database {}/{}", + authenticated_entity.slug, entity_for_database_slug, database_slug + ), + }) + } +} diff --git a/src/server/endpoints/update_database.rs b/src/server/endpoints/update_database.rs index 792915b..67ba2d6 100644 --- a/src/server/endpoints/update_database.rs +++ b/src/server/endpoints/update_database.rs @@ -19,7 +19,7 @@ async fn update_database( let database_slug = &path.database; let database = ayb_db.get_database(entity_slug, database_slug).await?; let authenticated_entity = unwrap_authenticated_entity(&authenticated_entity)?; - if can_manage_database(&authenticated_entity, &database) { + if can_manage_database(&authenticated_entity, &database, &ayb_db).await? { let public_sharing_level = get_optional_header(&req, "public-sharing-level")?; let mut partial_database = PartialDatabase { public_sharing_level: None, diff --git a/src/server/permissions.rs b/src/server/permissions.rs index 7a4b1db..a8ec94c 100644 --- a/src/server/permissions.rs +++ b/src/server/permissions.rs @@ -1,6 +1,10 @@ -use crate::ayb_db::models::{InstantiatedDatabase, InstantiatedEntity, PublicSharingLevel}; +use crate::ayb_db::db_interfaces::AybDb; +use crate::ayb_db::models::{ + EntityDatabaseSharingLevel, InstantiatedDatabase, InstantiatedEntity, PublicSharingLevel, +}; use crate::error::AybError; use crate::hosted_db::QueryMode; +use actix_web::web; fn is_owner(authenticated_entity: &InstantiatedEntity, database: &InstantiatedDatabase) -> bool { authenticated_entity.id == database.entity_id @@ -10,47 +14,85 @@ pub fn can_create_database( authenticated_entity: &InstantiatedEntity, desired_entity: &InstantiatedEntity, ) -> bool { - // An entity/user can only create databases on itself (for now) authenticated_entity.id == desired_entity.id } -pub fn can_discover_database( +pub async fn can_discover_database( authenticated_entity: &InstantiatedEntity, database: &InstantiatedDatabase, + ayb_db: &web::Data>, ) -> Result { let public_sharing_level = PublicSharingLevel::try_from(database.public_sharing_level)?; - Ok(is_owner(authenticated_entity, database) + if is_owner(authenticated_entity, database) || public_sharing_level == PublicSharingLevel::ReadOnly - || public_sharing_level == PublicSharingLevel::Fork) + || public_sharing_level == PublicSharingLevel::Fork + { + return Ok(true); + } + + let permission = ayb_db + .get_entity_database_permission(authenticated_entity, database) + .await?; + match permission { + Some(permission) => match EntityDatabaseSharingLevel::try_from(permission.sharing_level)? { + EntityDatabaseSharingLevel::Manager + | EntityDatabaseSharingLevel::ReadWrite + | EntityDatabaseSharingLevel::ReadOnly => Ok(true), + _ => Ok(false), + }, + None => Ok(false), + } } -pub fn can_manage_database( +pub async fn can_manage_database( authenticated_entity: &InstantiatedEntity, database: &InstantiatedDatabase, -) -> bool { - // An entity/user can only manage its own databases (for now) - is_owner(authenticated_entity, database) + ayb_db: &web::Data>, +) -> Result { + if is_owner(authenticated_entity, database) { + return Ok(true); + } + + let permission = ayb_db + .get_entity_database_permission(authenticated_entity, database) + .await?; + match permission { + Some(permission) => match EntityDatabaseSharingLevel::try_from(permission.sharing_level)? { + EntityDatabaseSharingLevel::Manager => Ok(true), + _ => Ok(false), + }, + None => Ok(false), + } } -pub fn highest_query_access_level( +pub async fn highest_query_access_level( authenticated_entity: &InstantiatedEntity, database: &InstantiatedDatabase, + ayb_db: &web::Data>, ) -> Result, AybError> { if is_owner(authenticated_entity, database) { - Ok(Some(QueryMode::ReadWrite)) + return Ok(Some(QueryMode::ReadWrite)); + } + let permission = ayb_db + .get_entity_database_permission(authenticated_entity, database) + .await?; + let access_level = match permission { + Some(permission) => match EntityDatabaseSharingLevel::try_from(permission.sharing_level)? { + EntityDatabaseSharingLevel::Manager | EntityDatabaseSharingLevel::ReadWrite => { + Some(QueryMode::ReadWrite) + } + EntityDatabaseSharingLevel::ReadOnly => Some(QueryMode::ReadOnly), + _ => None, + }, + None => None, + }; + if access_level.is_some() { + return Ok(access_level); } else if PublicSharingLevel::try_from(database.public_sharing_level)? == PublicSharingLevel::ReadOnly { - Ok(Some(QueryMode::ReadOnly)) - } else { - Ok(None) + return Ok(Some(QueryMode::ReadOnly)); } -} -pub fn can_manage_snapshots( - authenticated_entity: &InstantiatedEntity, - database: &InstantiatedDatabase, -) -> bool { - // An entity/user can only manage snapshots on its own databases (for now) - is_owner(authenticated_entity, database) + Ok(None) } diff --git a/src/server/server_runner.rs b/src/server/server_runner.rs index dd1d1b5..92f070d 100644 --- a/src/server/server_runner.rs +++ b/src/server/server_runner.rs @@ -5,7 +5,7 @@ use crate::server::config::read_config; use crate::server::config::AybConfigCors; use crate::server::endpoints::{ confirm_endpoint, create_db_endpoint, entity_details_endpoint, list_snapshots_endpoint, - log_in_endpoint, query_endpoint, register_endpoint, restore_snapshot_endpoint, + log_in_endpoint, query_endpoint, register_endpoint, restore_snapshot_endpoint, share_endpoint, update_db_endpoint, update_profile_endpoint, }; use crate::server::snapshots::execution::schedule_periodic_snapshots; @@ -33,7 +33,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(entity_details_endpoint) .service(update_profile_endpoint) .service(list_snapshots_endpoint) - .service(restore_snapshot_endpoint), + .service(restore_snapshot_endpoint) + .service(share_endpoint), ); } diff --git a/tests/e2e_tests/create_and_query_db_tests.rs b/tests/e2e_tests/create_and_query_db_tests.rs index 3eee1f8..d610dad 100644 --- a/tests/e2e_tests/create_and_query_db_tests.rs +++ b/tests/e2e_tests/create_and_query_db_tests.rs @@ -1,4 +1,4 @@ -use crate::e2e_tests::{FIRST_ENTITY_DB, FIRST_ENTITY_DB_CASED}; +use crate::e2e_tests::{FIRST_ENTITY_DB, FIRST_ENTITY_DB2, FIRST_ENTITY_DB_CASED}; use crate::utils::ayb::{create_database, query, query_no_api_token, set_default_url}; use ayb::client::config::ClientConfig; use std::collections::HashMap; @@ -14,6 +14,7 @@ pub fn test_create_and_query_db( create_database( &config_path, &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, "Error: Authenticated entity e2e-second can't create a database for entity e2e-first", )?; @@ -21,6 +22,7 @@ pub fn test_create_and_query_db( create_database( &config_path, &format!("{}bad", api_keys.get("first").unwrap()[0]), + FIRST_ENTITY_DB, "Error: Invalid API token", )?; @@ -28,6 +30,7 @@ pub fn test_create_and_query_db( create_database( &config_path, &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, "Successfully created e2e-first/test.sqlite", )?; @@ -35,9 +38,18 @@ pub fn test_create_and_query_db( create_database( &config_path, &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, "Error: Database already exists", )?; + // Can create another database with the appropriate user/key pair. + create_database( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB2, + "Successfully created e2e-first/another.sqlite", + )?; + // Can't query database with second account's API key query( &config_path, diff --git a/tests/e2e_tests/entity_details_and_profile_tests.rs b/tests/e2e_tests/entity_details_and_profile_tests.rs index 7ec4df8..2982465 100644 --- a/tests/e2e_tests/entity_details_and_profile_tests.rs +++ b/tests/e2e_tests/entity_details_and_profile_tests.rs @@ -12,7 +12,7 @@ pub fn test_entity_details_and_profile( &api_keys.get("first").unwrap()[0], FIRST_ENTITY_SLUG_CASED, // Entity slugs should be case-insensitive "csv", - "Database slug,Type\ntest.sqlite,sqlite", + "Database slug,Type\nanother.sqlite,sqlite\ntest.sqlite,sqlite", )?; // List databases from first account using the API key of the second account diff --git a/tests/e2e_tests/mod.rs b/tests/e2e_tests/mod.rs index b73481b..494e0ca 100644 --- a/tests/e2e_tests/mod.rs +++ b/tests/e2e_tests/mod.rs @@ -12,7 +12,9 @@ pub use snapshot_tests::test_snapshots; const FIRST_ENTITY_DB: &str = "e2e-first/test.sqlite"; const FIRST_ENTITY_DB_CASED: &str = "E2E-FiRST/test.sqlite"; +const FIRST_ENTITY_DB2: &str = "e2e-first/another.sqlite"; const FIRST_ENTITY_DB_SLUG: &str = "test.sqlite"; const FIRST_ENTITY_SLUG: &str = "e2e-first"; const FIRST_ENTITY_SLUG_CASED: &str = "E2E-FiRsT"; const SECOND_ENTITY_SLUG: &str = "e2e-second"; +const THIRD_ENTITY_SLUG: &str = "e2e-third"; diff --git a/tests/e2e_tests/permissions_tests.rs b/tests/e2e_tests/permissions_tests.rs index 5444459..bcc4c3d 100644 --- a/tests/e2e_tests/permissions_tests.rs +++ b/tests/e2e_tests/permissions_tests.rs @@ -1,11 +1,19 @@ -use crate::e2e_tests::{FIRST_ENTITY_DB, FIRST_ENTITY_SLUG}; -use crate::utils::ayb::{list_databases, query, update_database}; +use crate::e2e_tests::{ + FIRST_ENTITY_DB, FIRST_ENTITY_DB2, FIRST_ENTITY_SLUG, SECOND_ENTITY_SLUG, THIRD_ENTITY_SLUG, +}; +use crate::utils::ayb::{ + list_databases, list_snapshots, list_snapshots_match_output, query, share, update_database, +}; use std::collections::HashMap; pub async fn test_permissions( config_path: &str, api_keys: &HashMap>, ) -> Result<(), Box> { + // The first set of tests cover the various permissions afforded + // by the public sharing level (global no-access/fork/read-only + // permissions). + // While first entity has query access to database and can find it // in a list (it's the owner), the second one can't do either. query( @@ -21,7 +29,10 @@ pub async fn test_permissions( &api_keys.get("first").unwrap()[0], FIRST_ENTITY_SLUG, "csv", - "Database slug,Type\ntest.sqlite,sqlite", + // Note that while the first entity can see another.sqlite, + // the second/third entities, when granted access to + // test.sqlite, will not be able to see another.sqlite. + "Database slug,Type\nanother.sqlite,sqlite\ntest.sqlite,sqlite", )?; query( &config_path, @@ -76,7 +87,7 @@ pub async fn test_permissions( // With public read-only permissions, the second entity can issue // read-only (SELECT) queries, but not modify the database (e.g., - // INSERT). It should also still be able to discover the datbaase. + // INSERT). It should also still be able to discover the database. update_database( &config_path, &api_keys.get("first").unwrap()[0], @@ -107,6 +118,16 @@ pub async fn test_permissions( "csv", "Database slug,Type\ntest.sqlite,sqlite", )?; + // Read-only access to e2e-first/test.sqlite doesn't grant access + // to e2e-first/another.sqlite. + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB2, + "table", + "Error: Authenticated entity e2e-second can\'t query database e2e-first/another.sqlite", + )?; // With no public permissions, the second entity can't query or discover the database. update_database( @@ -132,6 +153,401 @@ pub async fn test_permissions( &format!("No queryable databases owned by {}", FIRST_ENTITY_SLUG), )?; - // TODO(marcua): When ready, test entity-database permissions. + // The second set of tests cover an entity's individual + // database-level permissions (no-access, read-only, read-write, + // manager). + + // Ensure we can't update permissions for owner, even if we're ourselves. + share( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, + FIRST_ENTITY_SLUG, + "no-access", + "Error: e2e-first owns e2e-first/test.sqlite, so their permissions can't be changed", + )?; + + // First entity grants second entity read-only access. + share( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, + SECOND_ENTITY_SLUG, + "read-only", + "Permissions for e2e-second on e2e-first/test.sqlite updated successfully", + )?; + // Second entity has read-only access. + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + " the_count \n-----------\n 4 \n\nRows: 1", + )?; + // Second entity can't modify database. + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "INSERT INTO test_table (fname, lname) VALUES (\"first permissions2\", \"last permissions2\");", + FIRST_ENTITY_DB, + "table", + "Error: Attempted to write to database while in read-only mode", + )?; + // Second entity can discover database. + list_databases( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + "Database slug,Type\ntest.sqlite,sqlite", + )?; + // Second entity can't manage snapshots on the database. + list_snapshots_match_output( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + "csv", + "Error: Authenticated entity e2e-second can't manage snapshots on database e2e-first/test.sqlite", + )?; + // Second entity can't update the database's metadata. + update_database( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + "fork", + "Error: Authenticated entity e2e-second can't update database e2e-first/test.sqlite", + )?; + // Second entity can't share the database with anyone else. + share( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + THIRD_ENTITY_SLUG, + "read-only", + "Error: Authenticated entity e2e-second can\'t set permissions for database e2e-first/test.sqlite", + )?; + // Third entity has no access (only the second entity got access). + query( + &config_path, + &api_keys.get("third").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + "Error: Authenticated entity e2e-third can't query database e2e-first/test.sqlite", + )?; + list_databases( + &config_path, + &api_keys.get("third").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + &format!("No queryable databases owned by {}", FIRST_ENTITY_SLUG), + )?; + + // First entity upgrades second entity's access to read-write. + share( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, + SECOND_ENTITY_SLUG, + "read-write", + "Permissions for e2e-second on e2e-first/test.sqlite updated successfully", + )?; + // Second entity can query database. + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + " the_count \n-----------\n 4 \n\nRows: 1", + )?; + // Even if the public sharing level of the database is read-only, + // the second entity will be able to modify the database. + update_database( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, + "read-only", + "Database e2e-first/test.sqlite updated successfully", + )?; + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "INSERT INTO test_table (fname, lname) VALUES (\"first permissions2\", \"last permissions2\");", + FIRST_ENTITY_DB, + "table", + "\nRows: 0", + )?; + update_database( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, + "no-access", + "Database e2e-first/test.sqlite updated successfully", + )?; + // Second entity can discover database. + list_databases( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + "Database slug,Type\ntest.sqlite,sqlite", + )?; + // Second entity can't manage snapshots on the database. + list_snapshots_match_output( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + "csv", + "Error: Authenticated entity e2e-second can't manage snapshots on database e2e-first/test.sqlite", + )?; + // Second entity can't update database metadata. + update_database( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + "fork", + "Error: Authenticated entity e2e-second can't update database e2e-first/test.sqlite", + )?; + // Second entity can't share the database with anyone else. + share( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + THIRD_ENTITY_SLUG, + "read-only", + "Error: Authenticated entity e2e-second can\'t set permissions for database e2e-first/test.sqlite", + )?; + // Third entity has no access. + query( + &config_path, + &api_keys.get("third").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + "Error: Authenticated entity e2e-third can't query database e2e-first/test.sqlite", + )?; + list_databases( + &config_path, + &api_keys.get("third").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + &format!("No queryable databases owned by {}", FIRST_ENTITY_SLUG), + )?; + + // First entity updates second entity to manager access. + share( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, + SECOND_ENTITY_SLUG, + "manager", + "Permissions for e2e-second on e2e-first/test.sqlite updated successfully", + )?; + // Second entity can query database. + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + " the_count \n-----------\n 5 \n\nRows: 1", + )?; + // Second entity can modify database. + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "INSERT INTO test_table (fname, lname) VALUES (\"first permissions2\", \"last permissions2\");", + FIRST_ENTITY_DB, + "table", + "\nRows: 0", + )?; + // Second entity can discover database. + list_databases( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + "Database slug,Type\ntest.sqlite,sqlite", + )?; + // Second entity can manage snapshots on the database. + let snapshots = list_snapshots( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, + "csv", + )?; + assert_ne!( + snapshots.len(), + 0, + "e2e-second should be able to list snapshots" + ); + // Access to e2e-first/test.sqlite doesn't grant access to + // e2e-first/another.sqlite. + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB2, + "table", + "Error: Authenticated entity e2e-second can\'t query database e2e-first/another.sqlite", + )?; + // Second entity can update database metadata. + update_database( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + "read-only", + "Database e2e-first/test.sqlite updated successfully", + )?; + // Third entity can query database. + query( + &config_path, + &api_keys.get("third").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + " the_count \n-----------\n 6 \n\nRows: 1", + )?; + // Third entity can't modify database. + query( + &config_path, + &api_keys.get("third").unwrap()[0], + "INSERT INTO test_table (fname, lname) VALUES (\"first permissions2\", \"last permissions2\");", + FIRST_ENTITY_DB, + "table", + "Error: Attempted to write to database while in read-only mode", + )?; + // Third entity can discover database. + list_databases( + &config_path, + &api_keys.get("third").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + "Database slug,Type\ntest.sqlite,sqlite", + )?; + // Second entity revokes public sharing access. + update_database( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + "no-access", + "Database e2e-first/test.sqlite updated successfully", + )?; + // Third entity is back to having no access. + query( + &config_path, + &api_keys.get("third").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + "Error: Authenticated entity e2e-third can't query database e2e-first/test.sqlite", + )?; + list_databases( + &config_path, + &api_keys.get("third").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + &format!("No queryable databases owned by {}", FIRST_ENTITY_SLUG), + )?; + + // Second entity can share the database with third entity. + share( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + THIRD_ENTITY_SLUG, + "read-only", + "Permissions for e2e-third on e2e-first/test.sqlite updated successfully", + )?; + // Third entity can query database. + query( + &config_path, + &api_keys.get("third").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + " the_count \n-----------\n 6 \n\nRows: 1", + )?; + // Third entity can't modify database. + query( + &config_path, + &api_keys.get("third").unwrap()[0], + "INSERT INTO test_table (fname, lname) VALUES (\"first permissions2\", \"last permissions2\");", + FIRST_ENTITY_DB, + "table", + "Error: Attempted to write to database while in read-only mode", + )?; + // Third entity can discover database. + list_databases( + &config_path, + &api_keys.get("third").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + "Database slug,Type\ntest.sqlite,sqlite", + )?; + // Second entity revokes third entity's access. + share( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + THIRD_ENTITY_SLUG, + "no-access", + "Permissions for e2e-third on e2e-first/test.sqlite updated successfully", + )?; + // Third entity is back to having no access. + query( + &config_path, + &api_keys.get("third").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + "Error: Authenticated entity e2e-third can't query database e2e-first/test.sqlite", + )?; + list_databases( + &config_path, + &api_keys.get("third").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + &format!("No queryable databases owned by {}", FIRST_ENTITY_SLUG), + )?; + + // A manager (second entity) can't modify the owner's (first entity's) access. + share( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_DB, + FIRST_ENTITY_SLUG, + "no-access", + "Error: e2e-first owns e2e-first/test.sqlite, so their permissions can't be changed", + )?; + + // First entity revoke's second entity's access. + share( + &config_path, + &api_keys.get("first").unwrap()[0], + FIRST_ENTITY_DB, + SECOND_ENTITY_SLUG, + "no-access", + "Permissions for e2e-second on e2e-first/test.sqlite updated successfully", + )?; + // Second entity now has no access. + query( + &config_path, + &api_keys.get("second").unwrap()[0], + "SELECT COUNT(*) AS the_count FROM test_table;", + FIRST_ENTITY_DB, + "table", + "Error: Authenticated entity e2e-second can't query database e2e-first/test.sqlite", + )?; + list_databases( + &config_path, + &api_keys.get("second").unwrap()[0], + FIRST_ENTITY_SLUG, + "csv", + &format!("No queryable databases owned by {}", FIRST_ENTITY_SLUG), + )?; + Ok(()) } diff --git a/tests/e2e_tests/registration_tests.rs b/tests/e2e_tests/registration_tests.rs index 545be26..e001098 100644 --- a/tests/e2e_tests/registration_tests.rs +++ b/tests/e2e_tests/registration_tests.rs @@ -1,5 +1,5 @@ use crate::ayb_assert_cmd; -use crate::e2e_tests::{FIRST_ENTITY_SLUG, SECOND_ENTITY_SLUG}; +use crate::e2e_tests::{FIRST_ENTITY_SLUG, SECOND_ENTITY_SLUG, THIRD_ENTITY_SLUG}; use crate::utils::ayb::register; use assert_cmd::prelude::*; use ayb::client::config::ClientConfig; @@ -215,15 +215,37 @@ pub fn test_registration( serde_json::to_string(&expected_config)? ); + // Start the registration process for a third user (e2e-third) + register( + &config_path, + server_url, + THIRD_ENTITY_SLUG, + "e2e-a-third@example.org", + "Check your email to finish registering e2e-third", + )?; + let entries = parse_smtp_log(&format!( + "tests/smtp_data_{}/e2e-a-third@example.org", + smtp_port + ))?; + assert_eq!(entries.len(), 1); + let third_token0 = extract_token(&entries[0])?; + let cmd = ayb_assert_cmd!("client", "confirm", &third_token0; { + "AYB_CLIENT_CONFIG_FILE" => format!("{}-throwaway", config_path), // Don't save this third account's credentials as our default token in the main configuration file. + "AYB_SERVER_URL" => server_url, + }); + let third_api_key0 = extract_api_key(cmd.get_output())?; + // To summarize where we are at this point // * User e2e-first has three API tokens (first_api_key[0...2]). We'll use these // interchangeably in subsequent tests. // * User e2e-second has one API token (second_api_key0) + // * User e2e-third has one API token (third_api_key0) let mut api_keys: HashMap> = HashMap::new(); api_keys.insert( "first".to_string(), vec![first_api_key0, first_api_key1, first_api_key2], ); api_keys.insert("second".to_string(), vec![second_api_key0]); + api_keys.insert("third".to_string(), vec![third_api_key0]); Ok(api_keys) } diff --git a/tests/utils/ayb.rs b/tests/utils/ayb.rs index d25830c..22ad595 100644 --- a/tests/utils/ayb.rs +++ b/tests/utils/ayb.rs @@ -22,9 +22,10 @@ macro_rules! ayb_assert_cmd { pub fn create_database( config: &str, api_key: &str, + database: &str, result: &str, ) -> Result<(), Box> { - let cmd = ayb_assert_cmd!("client", "--config", config, "create_database", "e2e-first/test.sqlite", "sqlite"; { + let cmd = ayb_assert_cmd!("client", "--config", config, "create_database", database, "sqlite"; { "AYB_API_TOKEN" => api_key, }); @@ -243,3 +244,19 @@ pub fn update_database( cmd.stdout(format!("{}\n", result)); Ok(()) } + +pub fn share( + config: &str, + api_key: &str, + database: &str, + entity: &str, + sharing_level: &str, + result: &str, +) -> Result<(), Box> { + let cmd = ayb_assert_cmd!("client", "--config", config, "share", database, entity, sharing_level; { + "AYB_API_TOKEN" => api_key, + }); + + cmd.stdout(format!("{}\n", result)); + Ok(()) +}