Skip to content

Commit

Permalink
Database sharing with individual entities (#487)
Browse files Browse the repository at this point in the history
* Database Create/Update/Delete for permissions

* Add NoAccess level (not for storage, just for API usage)

* Add CantSetOwnerPermissions error

* Add entity_database_permission endpoint

* Add client-side sharing support

* Introduce entity database permissions into highest_query_access_level

* Add managers to list of entities who can manage a database (go figure...)

* Add entity database sharing level logic to can_discover_database

* Replace snapshot-specific permissions with database management-specific ones (didn't need this level of granularity for now)

* Fix which entity's access is being removed, patch->post, clarify error message, and make CantSetOwnerPermissions print more prettily

* Tests to cover the various permissions the share endpoint opens

* Update comments, test that owner permissions are unmodifiable

* Access to one database owned by an entity doesn't grant access to another database owned by that entity

* Rename endpoint to 'share' and standardize order of databases per entity

* Ensure that ReadWrite permissions win over ReadOnly permissions if they exist

* Clippy

* Print stack trace on test failure
  • Loading branch information
marcua authored Dec 1, 2024
1 parent c09f586 commit 5eb2175
Show file tree
Hide file tree
Showing 21 changed files with 782 additions and 51 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,5 @@ jobs:
run: tests/set_up_e2e_env.sh
- name: Run tests
run: cargo test --verbose
env:
RUST_BACKTRACE: 1
81 changes: 79 additions & 2 deletions src/ayb_db/db_interfaces.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -30,6 +31,11 @@ pub trait AybDb: DynClone + Send + Sync {
method: &AuthenticationMethod,
) -> Result<InstantiatedAuthenticationMethod, AybError>;
async fn create_database(&self, database: &Database) -> Result<InstantiatedDatabase, AybError>;
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<InstantiatedEntity, AybError>;
async fn get_api_token(&self, short_token: &str) -> Result<APIToken, AybError>;
async fn get_database(
Expand All @@ -39,6 +45,11 @@ pub trait AybDb: DynClone + Send + Sync {
) -> Result<InstantiatedDatabase, AybError>;
async fn get_entity_by_slug(&self, entity_slug: &str) -> Result<InstantiatedEntity, AybError>;
async fn get_entity_by_id(&self, entity_id: i32) -> Result<InstantiatedEntity, AybError>;
async fn get_entity_database_permission(
&self,
entity: &InstantiatedEntity,
database: &InstantiatedDatabase,
) -> Result<Option<EntityDatabasePermission>, AybError>;
async fn update_database_by_id(
&self,
database_id: i32,
Expand All @@ -49,6 +60,10 @@ pub trait AybDb: DynClone + Send + Sync {
entity_id: i32,
entity: &PartialEntity,
) -> Result<InstantiatedEntity, AybError>;
async fn update_or_create_entity_database_permission(
&self,
permission: &EntityDatabasePermission,
) -> Result<(), AybError>;
async fn list_authentication_methods(
&self,
entity: &InstantiatedEntity,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -276,6 +310,26 @@ WHERE id = $1
Ok(entity)
}

async fn get_entity_database_permission(&self, entity: &InstantiatedEntity, database: &InstantiatedDatabase) -> Result<Option<EntityDatabasePermission>, AybError> {
let permission: Option<EntityDatabasePermission> = 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<InstantiatedDatabase, AybError> {
let mut query = QueryBuilder::new("UPDATE database SET");
let mut updated_field = false;
Expand Down Expand Up @@ -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<InstantiatedEntity, AybError> {
// Get or create logic inspired by https://stackoverflow.com/a/66337293
let mut tx = self.pool.begin().await?;
Expand Down Expand Up @@ -439,6 +515,7 @@ SELECT
public_sharing_level
FROM database
WHERE database.entity_id = $1
ORDER BY id DESC
"#,
)
.bind(entity.id)
Expand Down
16 changes: 10 additions & 6 deletions src/ayb_db/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 41 additions & 3 deletions src/client/cli.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -185,16 +185,28 @@ pub fn client_commands() -> Command {
.about("Update properties of a database")
.arg(arg!(<database> "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 <value> "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 <value> "The level of public access to enable for this database").value_parser(value_parser!(PublicSharingLevel)).required(true))
)
.subcommand(
Command::new("set_default_url")
.about("Set the default server URL for future requests in ayb.json")
.arg(arg!(<url> "The URL to use in the future")
.required(true))
)
.subcommand(
Command::new("share")
.about("Share a database with another entity")
.arg(arg!(<database> "The database to share (e.g., entity/database.sqlite)")
.value_parser(ValueParser::new(entity_database_parser))
.required(true)
)
.arg(arg!(<entity> "The entity with which to share")
.required(true))
.arg(arg!(<sharing_level> "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")
Expand Down Expand Up @@ -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::<EntityDatabasePath>("database"),
matches.get_one::<String>("entity"),
matches.get_one::<EntityDatabaseSharingLevel>("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(())
Expand Down
32 changes: 31 additions & 1 deletion src/client/http.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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
}
}
2 changes: 2 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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),
}
Expand Down
2 changes: 1 addition & 1 deletion src/server/endpoints/entity_details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/server/endpoints/list_snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ListSnapshotResult> = Vec::new();
if let Some(ref snapshot_config) = ayb_config.snapshots {
let snapshot_storage = SnapshotStorage::new(snapshot_config).await?;
Expand Down
2 changes: 2 additions & 0 deletions src/server/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod log_in;
mod query;
mod register;
mod restore_snapshot;
mod share;
mod update_database;
mod update_profile;

Expand All @@ -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;
3 changes: 2 additions & 1 deletion src/server/endpoints/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down
4 changes: 2 additions & 2 deletions src/server/endpoints/restore_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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
Expand Down
Loading

0 comments on commit 5eb2175

Please sign in to comment.