diff --git a/.gitignore b/.gitignore index d0cd6bbcc..254eea576 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ yarn.lock *.wasm *.sqlite* .envrc + +/private diff --git a/Cargo.lock b/Cargo.lock index ae4f01ee0..4508ec071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4084,7 +4084,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "permit-client-rs" version = "2.0.0" -source = "git+https://github.com/shuttle-hq/permit-client-rs?rev=27c7759#27c775918aa6f7522e0845d8775a8b63d4124f6b" +source = "git+https://github.com/shuttle-hq/permit-client-rs?rev=19085ba#19085ba73bb87c879731590f4a3a988e92d076ac" dependencies = [ "reqwest", "serde", @@ -5167,11 +5167,13 @@ name = "shuttle-admin" version = "0.43.0" dependencies = [ "anyhow", + "bytes", "clap", "dirs", "reqwest", "serde", "serde_json", + "shuttle-backends", "shuttle-common", "tokio", "toml", diff --git a/admin/Cargo.toml b/admin/Cargo.toml index 7b9bed567..2b7e1b7c8 100644 --- a/admin/Cargo.toml +++ b/admin/Cargo.toml @@ -6,8 +6,10 @@ publish = false [dependencies] shuttle-common = { workspace = true, features = ["models"] } +shuttle-backends = { workspace = true } anyhow = { workspace = true } +bytes = { workspace = true } clap = { workspace = true, features = ["env"] } dirs = { workspace = true } reqwest = { workspace = true, features = ["json"] } diff --git a/admin/src/args.rs b/admin/src/args.rs index 2e35d9e49..e6e170b38 100644 --- a/admin/src/args.rs +++ b/admin/src/args.rs @@ -1,6 +1,7 @@ use std::{fs, io, path::PathBuf}; use clap::{Error, Parser, Subcommand}; +use shuttle_common::models::user::UserId; #[derive(Parser, Debug)] pub struct Args { @@ -27,6 +28,11 @@ pub enum Command { /// Manage project names ProjectNames, + ChangeProjectOwner { + project_name: String, + new_user_id: UserId, + }, + /// Viewing and managing stats #[command(subcommand)] Stats(StatsCommand), diff --git a/admin/src/client.rs b/admin/src/client.rs index ddc6f595f..deb743cd9 100644 --- a/admin/src/client.rs +++ b/admin/src/client.rs @@ -1,4 +1,5 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use bytes::Bytes; use serde::{de::DeserializeOwned, Serialize}; use shuttle_common::models::{admin::ProjectResponse, stats, ToJson}; use tracing::trace; @@ -73,6 +74,15 @@ impl Client { self.get("/admin/projects").await } + pub async fn change_project_owner(&self, project_name: &str, new_user_id: &str) -> Result<()> { + self.get_raw(&format!( + "/admin/projects/change-owner/{project_name}/{new_user_id}" + )) + .await?; + + Ok(()) + } + pub async fn get_load(&self) -> Result { self.get("/admin/stats/load").await } @@ -130,15 +140,20 @@ impl Client { .context("failed to extract json body from delete response") } - async fn get(&self, path: &str) -> Result { - reqwest::Client::new() + async fn get_raw(&self, path: &str) -> Result { + let res = reqwest::Client::new() .get(format!("{}{}", self.api_url, path)) .bearer_auth(&self.api_key) .send() .await - .context("failed to make get request")? - .to_json() - .await - .context("failed to post text body from response") + .context("making request")?; + if !res.status().is_success() { + bail!("API call returned non-2xx: {:?}", res); + } + res.bytes().await.context("getting response body") + } + + async fn get(&self, path: &str) -> Result { + serde_json::from_slice(&self.get_raw(path).await?).context("deserializing body") } } diff --git a/admin/src/config.rs b/admin/src/config.rs index 5b63f955a..df7b6ecd3 100644 --- a/admin/src/config.rs +++ b/admin/src/config.rs @@ -1,6 +1,10 @@ use std::{fs, path::PathBuf}; pub fn get_api_key() -> String { + if let Ok(s) = std::env::var("SHUTTLE_API_KEY") { + return s; + } + let data = fs::read_to_string(config_path()).expect("shuttle config file to exist"); let toml: toml::Value = toml::from_str(&data).expect("to parse shuttle config file"); diff --git a/admin/src/main.rs b/admin/src/main.rs index 21c5a338a..047bd6663 100644 --- a/admin/src/main.rs +++ b/admin/src/main.rs @@ -4,10 +4,7 @@ use shuttle_admin::{ client::Client, config::get_api_key, }; -use std::{ - collections::{hash_map::RandomState, HashMap}, - fmt::Write, -}; +use shuttle_backends::project_name::ProjectName; use tracing::trace; #[tokio::main] @@ -21,131 +18,63 @@ async fn main() { let api_key = get_api_key(); let client = Client::new(args.api_url.clone(), api_key); - let res = match args.command { - Command::Revive => client.revive().await.expect("revive to succeed"), - Command::Destroy => client.destroy().await.expect("destroy to succeed"), + match args.command { + Command::Revive => { + let s = client.revive().await.expect("revive to succeed"); + println!("{s}"); + } + Command::Destroy => { + let s = client.destroy().await.expect("destroy to succeed"); + println!("{s}"); + } Command::Acme(AcmeCommand::CreateAccount { email, acme_server }) => { let account = client .acme_account_create(&email, acme_server) .await .expect("to create ACME account"); - let mut res = String::new(); - writeln!(res, "Details of ACME account are as follow. Keep this safe as it will be needed to create certificates in the future").unwrap(); - writeln!(res, "{}", serde_json::to_string_pretty(&account).unwrap()).unwrap(); - - res + println!("Details of ACME account are as follow. Keep this safe as it will be needed to create certificates in the future"); + println!("{}", serde_json::to_string_pretty(&account).unwrap()); } Command::Acme(AcmeCommand::Request { fqdn, project, credentials, - }) => client - .acme_request_certificate(&fqdn, &project, &credentials) - .await - .expect("to get a certificate challenge response"), + }) => { + let s = client + .acme_request_certificate(&fqdn, &project, &credentials) + .await + .expect("to get a certificate challenge response"); + println!("{s}"); + } Command::Acme(AcmeCommand::RenewCustomDomain { fqdn, project, credentials, - }) => client - .acme_renew_custom_domain_certificate(&fqdn, &project, &credentials) - .await - .expect("to get a certificate challenge response"), - Command::Acme(AcmeCommand::RenewGateway { credentials }) => client - .acme_renew_gateway_certificate(&credentials) - .await - .expect("to get a certificate challenge response"), + }) => { + let s = client + .acme_renew_custom_domain_certificate(&fqdn, &project, &credentials) + .await + .expect("to get a certificate challenge response"); + println!("{s}"); + } + Command::Acme(AcmeCommand::RenewGateway { credentials }) => { + let s = client + .acme_renew_gateway_certificate(&credentials) + .await + .expect("to get a certificate challenge response"); + println!("{s}"); + } Command::ProjectNames => { let projects = client .get_projects() .await .expect("to get list of projects"); - - let projects: HashMap = HashMap::from_iter( - projects - .into_iter() - .map(|project| (project.project_name, project.account_name)), - ); - - let mut res = String::new(); - - for (project_name, account_name) in &projects { - let mut issues = Vec::new(); - let cleaned_name = project_name.to_lowercase(); - - // Were there any uppercase characters - if &cleaned_name != project_name { - // Since there were uppercase characters, will the new name clash with any existing projects - if let Some(other_account) = projects.get(&cleaned_name) { - if other_account == account_name { - issues.push( - "changing to lower case will clash with same owner".to_string(), - ); - } else { - issues.push(format!( - "changing to lower case will clash with another owner: {other_account}" - )); - } - } - } - - let cleaned_underscore = cleaned_name.replace('_', "-"); - // Were there any underscore cleanups - if cleaned_underscore != cleaned_name { - // Since there were underscore cleanups, will the new name clash with any existing projects - if let Some(other_account) = projects.get(&cleaned_underscore) { - if other_account == account_name { - issues - .push("cleaning underscore will clash with same owner".to_string()); - } else { - issues.push(format!( - "cleaning underscore will clash with another owner: {other_account}" - )); - } - } - } - - let cleaned_separator_name = cleaned_underscore.trim_matches('-'); - // Were there any dash cleanups - if cleaned_separator_name != cleaned_underscore { - // Since there were dash cleanups, will the new name clash with any existing projects - if let Some(other_account) = projects.get(cleaned_separator_name) { - if other_account == account_name { - issues.push("cleaning dashes will clash with same owner".to_string()); - } else { - issues.push(format!( - "cleaning dashes will clash with another owner: {other_account}" - )); - } - } - } - - // Are reserved words used - match cleaned_separator_name { - "shuttleapp" | "shuttle" => issues.push("is a reserved name".to_string()), - _ => {} - } - - // Is it longer than 63 chars - if cleaned_separator_name.len() > 63 { - issues.push("final name is too long".to_string()); - } - - // Only report of problem projects - if !issues.is_empty() { - writeln!(res, "{project_name}") - .expect("to write name of project name having issues"); - - for issue in issues { - writeln!(res, "\t- {issue}").expect("to write issue with project name"); - } - - writeln!(res).expect("to write a new line"); + for p in projects { + if !ProjectName::is_valid(&p.project_name) { + println!("{}", p.project_name); } } - - res } Command::Stats(StatsCommand::Load { clear }) => { let resp = if clear { @@ -156,16 +85,24 @@ async fn main() { let has_capacity = if resp.has_capacity { "a" } else { "no" }; - format!( + println!( "Currently {} builds are running and there is {} capacity for new builds", resp.builds_count, has_capacity ) } Command::IdleCch => { client.idle_cch().await.expect("cch projects to be idled"); - "Idled CCH projects".to_string() + println!("Idled CCH projects") + } + Command::ChangeProjectOwner { + project_name, + new_user_id, + } => { + client + .change_project_owner(&project_name, &new_user_id) + .await + .unwrap(); + println!("Changed project owner: {project_name} -> {new_user_id}") } }; - - println!("{res}"); } diff --git a/backends/Cargo.toml b/backends/Cargo.toml index 79fc79452..d2441ed81 100644 --- a/backends/Cargo.toml +++ b/backends/Cargo.toml @@ -24,11 +24,12 @@ opentelemetry-appender-tracing = { workspace = true } opentelemetry-http = { workspace = true } opentelemetry-otlp = { workspace = true } pin-project = { workspace = true } -permit-client-rs = { git = "https://github.com/shuttle-hq/permit-client-rs", rev = "27c7759" } +permit-client-rs = { git = "https://github.com/shuttle-hq/permit-client-rs", rev = "19085ba" } permit-pdp-client-rs = { git = "https://github.com/shuttle-hq/permit-pdp-client-rs", rev = "37c7296" } portpicker = { workspace = true, optional = true } reqwest = { workspace = true, features = ["json"] } # keep locked to not accidentally invalidate someone's project name +# higher versions have a lot more false positives rustrict = { version = "=0.7.12" } serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true } diff --git a/backends/src/client/permit.rs b/backends/src/client/permit.rs index 11f009c08..850811883 100644 --- a/backends/src/client/permit.rs +++ b/backends/src/client/permit.rs @@ -1,16 +1,23 @@ -use std::fmt::{Debug, Display}; +use std::{ + fmt::{Debug, Display}, + str::FromStr, +}; use async_trait::async_trait; use http::StatusCode; use permit_client_rs::{ apis::{ + relationship_tuples_api::{ + create_relationship_tuple, delete_relationship_tuple, list_relationship_tuples, + }, resource_instances_api::{create_resource_instance, delete_resource_instance}, - role_assignments_api::{assign_role, unassign_role}, + role_assignments_api::{assign_role, list_role_assignments, unassign_role}, users_api::{create_user, delete_user, get_user}, Error as PermitClientError, }, models::{ - ResourceInstanceCreate, RoleAssignmentCreate, RoleAssignmentRemove, UserCreate, UserRead, + RelationshipTupleCreate, RelationshipTupleDelete, ResourceInstanceCreate, + RoleAssignmentCreate, RoleAssignmentRemove, UserCreate, UserRead, }, }; use permit_pdp_client_rs::{ @@ -24,40 +31,122 @@ use permit_pdp_client_rs::{ }, models::{AuthorizationQuery, Resource, User, UserPermissionsQuery, UserPermissionsResult}, }; -use shuttle_common::claims::AccountTier; +use serde::{Deserialize, Serialize}; +use shuttle_common::{claims::AccountTier, models::organization}; #[async_trait] pub trait PermissionsDal { // User management /// Get a user with the given ID - async fn get_user(&self, user_id: &str) -> Result; + async fn get_user(&self, user_id: &str) -> Result; /// Delete a user with the given ID - async fn delete_user(&self, user_id: &str) -> Result<(), Error>; + async fn delete_user(&self, user_id: &str) -> Result<()>; /// Create a new user and set their tier correctly - async fn new_user(&self, user_id: &str) -> Result; + async fn new_user(&self, user_id: &str) -> Result; /// Set a user to be a Pro user - async fn make_pro(&self, user_id: &str) -> Result<(), Error>; + async fn make_pro(&self, user_id: &str) -> Result<()>; /// Set a user to be a Basic user - async fn make_basic(&self, user_id: &str) -> Result<(), Error>; + async fn make_basic(&self, user_id: &str) -> Result<()>; // Project management /// Creates a Project resource and assigns the user as admin for that project - async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error>; + async fn create_project(&self, user_id: &str, project_id: &str) -> Result<()>; /// Deletes a Project resource - async fn delete_project(&self, project_id: &str) -> Result<(), Error>; + async fn delete_project(&self, project_id: &str) -> Result<()>; // Organization management - ////// TODO + /// Creates an Organization resource and assigns the user as admin for the organization + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<()>; + + /// Deletes an Organization resource + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<()>; + + /// Get a list of all the organizations a user has access to + async fn get_organizations(&self, user_id: &str) -> Result>; + + /// Get a list of all project IDs that belong to an organization + async fn get_organization_projects(&self, user_id: &str, org_id: &str) -> Result>; + + /// Transfers a project from a user to another user + async fn transfer_project_to_user( + &self, + user_id: &str, + project_id: &str, + new_user_id: &str, + ) -> Result<()>; + + /// Transfers a project from a user to an organization + async fn transfer_project_to_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<()>; + + /// Transfers a project from an organization to a user + async fn transfer_project_from_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<()>; + + /// Add a user as a normal member to an organization + async fn add_organization_member( + &self, + admin_user: &str, + org_id: &str, + user_id: &str, + ) -> Result<()>; + + /// Remove a user from an organization + async fn remove_organization_member( + &self, + admin_user: &str, + org_id: &str, + user_id: &str, + ) -> Result<()>; + + /// Get a list of all the members of an organization + async fn get_organization_members( + &self, + user_id: &str, + org_id: &str, + ) -> Result>; // Permissions queries /// Get list of all projects user has permissions for - async fn get_user_projects(&self, user_id: &str) -> Result, Error>; + async fn get_user_projects(&self, user_id: &str) -> Result>; /// Check if user can perform action on this project - async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result; + async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result; +} + +/// Simple details of an organization to create +#[derive(Debug, PartialEq)] +pub struct Organization { + /// Unique identifier for the organization. Should be `org_{ulid}` + pub id: String, + + /// The name used to display the organization in the UI + pub display_name: String, +} + +#[derive(Deserialize, Serialize)] +/// The attributes stored with each organization resource +struct OrganizationAttributes { + display_name: String, +} + +impl OrganizationAttributes { + fn new(org: &Organization) -> Self { + Self { + display_name: org.display_name.to_string(), + } + } } /// Wrapper for the Permit.io API and PDP (Policy decision point) API @@ -104,22 +193,22 @@ impl Client { #[async_trait] impl PermissionsDal for Client { - async fn get_user(&self, user_id: &str) -> Result { + async fn get_user(&self, user_id: &str) -> Result { Ok(get_user(&self.api, &self.proj_id, &self.env_id, user_id).await?) } - async fn delete_user(&self, user_id: &str) -> Result<(), Error> { + async fn delete_user(&self, user_id: &str) -> Result<()> { Ok(delete_user(&self.api, &self.proj_id, &self.env_id, user_id).await?) } - async fn new_user(&self, user_id: &str) -> Result { + async fn new_user(&self, user_id: &str) -> Result { let user = self.create_user(user_id).await?; self.make_basic(&user.id.to_string()).await?; self.get_user(&user.id.to_string()).await } - async fn make_pro(&self, user_id: &str) -> Result<(), Error> { + async fn make_pro(&self, user_id: &str) -> Result<()> { let user = self.get_user(user_id).await?; if user.roles.is_some_and(|roles| { @@ -133,7 +222,7 @@ impl PermissionsDal for Client { self.assign_role(user_id, &AccountTier::Pro).await } - async fn make_basic(&self, user_id: &str) -> Result<(), Error> { + async fn make_basic(&self, user_id: &str) -> Result<()> { let user = self.get_user(user_id).await?; if user @@ -146,7 +235,7 @@ impl PermissionsDal for Client { self.assign_role(user_id, &AccountTier::Basic).await } - async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> { + async fn create_project(&self, user_id: &str, project_id: &str) -> Result<()> { if let Err(e) = create_resource_instance( &self.api, &self.proj_id, @@ -177,7 +266,7 @@ impl PermissionsDal for Client { Ok(()) } - async fn delete_project(&self, project_id: &str) -> Result<(), Error> { + async fn delete_project(&self, project_id: &str) -> Result<()> { Ok(delete_resource_instance( &self.api, &self.proj_id, @@ -187,7 +276,7 @@ impl PermissionsDal for Client { .await?) } - async fn get_user_projects(&self, user_id: &str) -> Result, Error> { + async fn get_user_projects(&self, user_id: &str) -> Result> { let perms = get_user_permissions_user_permissions_post( &self.pdp, UserPermissionsQuery { @@ -207,7 +296,7 @@ impl PermissionsDal for Client { Ok(perms.into_values().collect()) } - async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result { + async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result { // NOTE: This API function was modified in upstream to use AuthorizationQuery let res = is_allowed_allowed_post( &self.pdp, @@ -232,183 +321,330 @@ impl PermissionsDal for Client { Ok(res.allow.unwrap_or_default()) } + + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<()> { + if !self.allowed_org(user_id, &org.id, "create").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: + "User does not have permission to create organization. Are you a pro user?" + .to_owned(), + entity: "Organization".to_owned(), + })); + } + + if !self.get_organizations(user_id).await?.is_empty() { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::BAD_REQUEST, + content: "User already has an organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + if let Err(e) = create_resource_instance( + &self.api, + &self.proj_id, + &self.env_id, + ResourceInstanceCreate { + key: org.id.to_owned(), + tenant: "default".to_owned(), + resource: "Organization".to_owned(), + attributes: serde_json::to_value(OrganizationAttributes::new(org)).ok(), + }, + ) + .await + { + // Early return all errors except 409's (project already exists) + let e: Error = e.into(); + if let Error::ResponseError(ref re) = e { + if re.status != StatusCode::CONFLICT { + return Err(e); + } + } else { + return Err(e); + } + } + + self.assign_resource_role(user_id, format!("Organization:{}", org.id), "admin") + .await?; + + Ok(()) + } + + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<()> { + if !self.allowed_org(user_id, org_id, "manage").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to delete the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + let projects = self.get_organization_projects(user_id, org_id).await?; + + if !projects.is_empty() { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::BAD_REQUEST, + content: "Organization still has projects".to_owned(), + entity: "Organization".to_owned(), + })); + } + + Ok(delete_resource_instance( + &self.api, + &self.proj_id, + &self.env_id, + format!("Organization:{org_id}").as_str(), + ) + .await?) + } + + async fn get_organization_projects(&self, user_id: &str, org_id: &str) -> Result> { + if !self.allowed_org(user_id, org_id, "view").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to view the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + let relationships = list_relationship_tuples( + &self.api, + &self.proj_id, + &self.env_id, + Some(true), + None, + None, + Some("default"), + Some(&format!("Organization:{org_id}")), + Some("parent"), + None, + Some("Project"), + None, + ) + .await?; + + let projects = relationships + .into_iter() + .map(|rel| rel.object_details.expect("to have object details").key) + .collect(); + + Ok(projects) + } + + async fn get_organizations(&self, user_id: &str) -> Result> { + let perms = get_user_permissions_user_permissions_post( + &self.pdp, + UserPermissionsQuery { + user: Box::new(User { + key: user_id.to_owned(), + ..Default::default() + }), + resource_types: Some(vec!["Organization".to_owned()]), + tenants: Some(vec!["default".to_owned()]), + ..Default::default() + }, + None, + None, + ) + .await?; + + let mut res = Vec::with_capacity(perms.len()); + + for perm in perms.into_values() { + if let Some(resource) = perm.resource { + let attributes = resource.attributes.unwrap_or_default(); + let org = serde_json::from_value::(attributes) + .expect("to read organization attributes"); + + res.push(organization::Response { + id: resource.key, + display_name: org.display_name, + is_admin: perm + .roles + .unwrap_or_default() + .contains(&"admin".to_string()), + }); + } + } + + Ok(res) + } + + async fn transfer_project_to_user( + &self, + user_id: &str, + project_id: &str, + new_user_id: &str, + ) -> Result<()> { + self.unassign_resource_role(user_id, format!("Project:{project_id}"), "admin") + .await?; + + self.assign_resource_role(new_user_id, format!("Project:{project_id}"), "admin") + .await?; + + Ok(()) + } + + async fn transfer_project_to_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<()> { + if !self.allowed_org(user_id, org_id, "manage").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to modify the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + self.unassign_resource_role(user_id, format!("Project:{project_id}"), "admin") + .await?; + + self.assign_relationship( + format!("Organization:{org_id}"), + "parent", + format!("Project:{project_id}"), + ) + .await?; + + Ok(()) + } + + async fn transfer_project_from_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<()> { + if !self.allowed_org(user_id, org_id, "manage").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to modify the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + self.assign_resource_role(user_id, format!("Project:{project_id}"), "admin") + .await?; + + self.unassign_relationship( + format!("Organization:{org_id}"), + "parent", + format!("Project:{project_id}"), + ) + .await?; + + Ok(()) + } + + async fn add_organization_member( + &self, + admin_user: &str, + org_id: &str, + user_id: &str, + ) -> Result<()> { + if !self.allowed_org(admin_user, org_id, "manage").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to modify the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + let user = self.get_user(user_id).await?; + + if !user + .roles + .is_some_and(|roles| roles.iter().any(|r| r.role == AccountTier::Pro.to_string())) + { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::BAD_REQUEST, + content: "Only Pro users can be added to an organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + self.assign_resource_role(user_id, format!("Organization:{org_id}"), "member") + .await?; + + Ok(()) + } + + async fn remove_organization_member( + &self, + admin_user: &str, + org_id: &str, + user_id: &str, + ) -> Result<()> { + if admin_user == user_id { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::BAD_REQUEST, + content: "Cannot remove yourself from an organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + if !self.allowed_org(admin_user, org_id, "manage").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to modify the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + self.unassign_resource_role(user_id, format!("Organization:{org_id}"), "member") + .await?; + + Ok(()) + } + + async fn get_organization_members( + &self, + user_id: &str, + org_id: &str, + ) -> Result> { + if !self.allowed_org(user_id, org_id, "view").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to view the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + let assignments = list_role_assignments( + &self.api, + &self.proj_id, + &self.env_id, + None, + None, + Some("default"), + None, + Some(&format!("Organization:{org_id}")), + None, + None, + None, + ) + .await?; + + let members = assignments + .into_iter() + .map(|assignment| organization::MemberResponse { + id: assignment.user, + role: organization::MemberRole::from_str(&assignment.role) + .unwrap_or(organization::MemberRole::Member), + }) + .collect(); + + Ok(members) + } } // Helpers for trait methods impl Client { - // /// Assigns a user to an org directly without creating the org first - // pub async fn create_organization(&self, user_id: &str, org_name: &str) -> Result<(), Error> { - // self.api - // .post( - // &format!("{}/resource_instances", self.facts), - // json!({ - // "key": org_name, - // "tenant": "default", - // "resource": "Organization", - // }), - // None, - // ) - // .await?; - - // self.api - // .post( - // &format!("{}/role_assignments", self.facts), - // json!({ - // "role": "admin", - // "resource_instance": format!("Organization:{org_name}"), - // "tenant": "default", - // "user": user_id, - // }), - // None, - // ) - // .await - // } - - // pub async fn delete_organization(&self, org_id: &str) -> Result<(), Error> { - // self.api - // .request( - // Method::DELETE, - // &format!("{}/resource_instances/{org_id}", self.facts), - // None::<()>, - // None, - // ) - // .await - // } - - // pub async fn get_organizations(&self, user_id: &str) -> Result<(), Error> { - // self.api - // .get( - // &format!( - // "{}/role_assignments?user={user_id}&resource=Organization", - // self.facts - // ), - // None, - // ) - // .await - // } - - // pub async fn is_organization_admin( - // &self, - // user_id: &str, - // org_name: &str, - // ) -> Result { - // let res: Vec = self - // .api - // .get( - // &format!( - // "{}/role_assignments?user={user_id}&resource_instance=Organization:{org_name}", - // self.facts - // ), - // None, - // ) - // .await?; - - // Ok(res[0].as_object().unwrap()["role"].as_str().unwrap() == "admin") - // } - - // pub async fn create_organization_project( - // &self, - // org_name: &str, - // project_id: &str, - // ) -> Result<(), Error> { - // self.api - // .post( - // &format!("{}/relationship_tuples", self.facts), - // json!({ - // "subject": format!("Organization:{org_name}"), - // "tenant": "default", - // "relation": "parent", - // "object": format!("Project:{project_id}"), - // }), - // None, - // ) - // .await - // } - - // pub async fn delete_organization_project( - // &self, - // org_name: &str, - // project_id: &str, - // ) -> Result<(), Error> { - // self.api - // .delete( - // &format!("{}/relationship_tuples", self.facts), - // json!({ - // "subject": format!("Organization:{org_name}"), - // "relation": "parent", - // "object": format!("Project:{project_id}"), - // }), - // None, - // ) - // .await - // } - - // pub async fn get_organization_projects( - // &self, - // org_name: &str, - // ) -> Result, Error> { - // self.api - // .get( - // &format!( - // "{}/relationship_tuples?subject=Organization:{org_name}&detailed=true", - // self.facts - // ), - // None, - // ) - // .await - // } - - // pub async fn get_organization_members(&self, org_name: &str) -> Result, Error> { - // self.api - // .get( - // &format!( - // "{}/role_assignments?resource_instance=Organization:{org_name}&role=member", - // self.facts - // ), - // None, - // ) - // .await - // } - - // pub async fn create_organization_member( - // &self, - // org_name: &str, - // user_id: &str, - // ) -> Result<(), Error> { - // self.api - // .post( - // &format!("{}/role_assignments", self.facts), - // json!({ - // "role": "member", - // "resource_instance": format!("Organization:{org_name}"), - // "tenant": "default", - // "user": user_id, - // }), - // None, - // ) - // .await - // } - - // pub async fn delete_organization_member( - // &self, - // org_name: &str, - // user_id: &str, - // ) -> Result<(), Error> { - // self.api - // .delete( - // &format!("{}/role_assignments", self.facts), - // json!({ - // "role": "member", - // "resource_instance": format!("Organization:{org_name}"), - // "tenant": "default", - // "user": user_id, - // }), - // None, - // ) - // .await - // } - - async fn create_user(&self, user_id: &str) -> Result { + async fn create_user(&self, user_id: &str) -> Result { Ok(create_user( &self.api, &self.proj_id, @@ -421,7 +657,7 @@ impl Client { .await?) } - async fn assign_role(&self, user_id: &str, role: &AccountTier) -> Result<(), Error> { + async fn assign_role(&self, user_id: &str, role: &AccountTier) -> Result<()> { assign_role( &self.api, &self.proj_id, @@ -438,7 +674,7 @@ impl Client { Ok(()) } - async fn unassign_role(&self, user_id: &str, role: &AccountTier) -> Result<(), Error> { + async fn unassign_role(&self, user_id: &str, role: &AccountTier) -> Result<()> { unassign_role( &self.api, &self.proj_id, @@ -460,7 +696,7 @@ impl Client { user_id: &str, resource_instance: String, role: &str, - ) -> Result<(), Error> { + ) -> Result<()> { assign_role( &self.api, &self.proj_id, @@ -477,12 +713,12 @@ impl Client { Ok(()) } - async fn _unassign_resource_role( + async fn unassign_resource_role( &self, user_id: &str, resource_instance: String, role: &str, - ) -> Result<(), Error> { + ) -> Result<()> { unassign_role( &self.api, &self.proj_id, @@ -499,7 +735,71 @@ impl Client { Ok(()) } - pub async fn sync_pdp(&self) -> Result<(), Error> { + async fn allowed_org(&self, user_id: &str, org_id: &str, action: &str) -> Result { + // NOTE: This API function was modified in upstream to use AuthorizationQuery + let res = is_allowed_allowed_post( + &self.pdp, + AuthorizationQuery { + user: Box::new(User { + key: user_id.to_owned(), + ..Default::default() + }), + action: action.to_owned(), + resource: Box::new(Resource { + r#type: "Organization".to_string(), + key: Some(org_id.to_owned()), + tenant: Some("default".to_owned()), + ..Default::default() + }), + ..Default::default() + }, + None, + None, + ) + .await?; + + Ok(res.allow.unwrap_or_default()) + } + + async fn assign_relationship(&self, subject: String, role: &str, object: String) -> Result<()> { + create_relationship_tuple( + &self.api, + &self.proj_id, + &self.env_id, + RelationshipTupleCreate { + relation: role.to_owned(), + tenant: Some("default".to_owned()), + subject, + object, + }, + ) + .await?; + + Ok(()) + } + + async fn unassign_relationship( + &self, + subject: String, + role: &str, + object: String, + ) -> Result<()> { + delete_relationship_tuple( + &self.api, + &self.proj_id, + &self.env_id, + RelationshipTupleDelete { + relation: role.to_owned(), + subject, + object, + }, + ) + .await?; + + Ok(()) + } + + pub async fn sync_pdp(&self) -> Result<()> { trigger_policy_update_policy_updater_trigger_post(&self.pdp).await?; trigger_policy_data_update_data_updater_trigger_post(&self.pdp).await?; @@ -522,7 +822,7 @@ mod admin { impl Client { /// Copy and overwrite a permit env's policies to another env. /// Requires a project level API key. - pub async fn copy_environment(&self, target_env: &str) -> Result<(), Error> { + pub async fn copy_environment(&self, target_env: &str) -> Result<()> { copy_environment( &self.api, &self.proj_id, @@ -572,6 +872,7 @@ pub enum Error { #[error("response error: {0}")] ResponseError(ResponseContent), } +pub type Result = std::result::Result; #[derive(Debug)] pub struct ResponseContent { pub status: reqwest::StatusCode, diff --git a/backends/src/project_name.rs b/backends/src/project_name.rs index 87d43beeb..1d12dec58 100644 --- a/backends/src/project_name.rs +++ b/backends/src/project_name.rs @@ -36,7 +36,7 @@ impl ProjectName { fn is_profanity_free(name: &str) -> bool { let (_censored, analysis) = Censor::from_str(name).censor_and_analyze(); - !analysis.is(Type::MODERATE_OR_HIGHER) + !analysis.is(Type::SEVERE) // based on existing names, MODERATE_OR_HIGHER seems too strict } fn is_reserved(name: &str) -> bool { diff --git a/backends/src/test_utils/gateway.rs b/backends/src/test_utils/gateway.rs index fe026fece..7257b4e00 100644 --- a/backends/src/test_utils/gateway.rs +++ b/backends/src/test_utils/gateway.rs @@ -4,6 +4,7 @@ use async_trait::async_trait; use permit_client_rs::models::UserRead; use permit_pdp_client_rs::models::UserPermissionsResult; use serde::Serialize; +use shuttle_common::models::organization; use tokio::sync::Mutex; use wiremock::{ http, @@ -11,7 +12,10 @@ use wiremock::{ Mock, MockServer, Request, ResponseTemplate, }; -use crate::client::{permit::Error, PermissionsDal}; +use crate::client::{ + permit::{Organization, Result}, + PermissionsDal, +}; pub async fn get_mocked_gateway_server() -> MockServer { let mock_server = MockServer::start().await; @@ -97,12 +101,12 @@ pub struct PermissionsMock { #[async_trait] impl PermissionsDal for PermissionsMock { - async fn get_user(&self, user_id: &str) -> Result { + async fn get_user(&self, user_id: &str) -> Result { self.calls.lock().await.push(format!("get_user {user_id}")); Ok(Default::default()) } - async fn delete_user(&self, user_id: &str) -> Result<(), Error> { + async fn delete_user(&self, user_id: &str) -> Result<()> { self.calls .lock() .await @@ -110,17 +114,17 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn new_user(&self, user_id: &str) -> Result { + async fn new_user(&self, user_id: &str) -> Result { self.calls.lock().await.push(format!("new_user {user_id}")); Ok(Default::default()) } - async fn make_pro(&self, user_id: &str) -> Result<(), Error> { + async fn make_pro(&self, user_id: &str) -> Result<()> { self.calls.lock().await.push(format!("make_pro {user_id}")); Ok(()) } - async fn make_basic(&self, user_id: &str) -> Result<(), Error> { + async fn make_basic(&self, user_id: &str) -> Result<()> { self.calls .lock() .await @@ -128,7 +132,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> { + async fn create_project(&self, user_id: &str, project_id: &str) -> Result<()> { self.calls .lock() .await @@ -136,7 +140,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn delete_project(&self, project_id: &str) -> Result<(), Error> { + async fn delete_project(&self, project_id: &str) -> Result<()> { self.calls .lock() .await @@ -144,7 +148,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn get_user_projects(&self, user_id: &str) -> Result, Error> { + async fn get_user_projects(&self, user_id: &str) -> Result> { self.calls .lock() .await @@ -152,11 +156,116 @@ impl PermissionsDal for PermissionsMock { Ok(vec![]) } - async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result { + async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result { self.calls .lock() .await .push(format!("allowed {user_id} {project_id} {action}")); Ok(true) } + + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<()> { + self.calls.lock().await.push(format!( + "create_organization {user_id} {} {}", + org.id, org.display_name + )); + Ok(()) + } + + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<()> { + self.calls + .lock() + .await + .push(format!("delete_organization {user_id} {org_id}")); + Ok(()) + } + + async fn get_organization_projects(&self, user_id: &str, org_id: &str) -> Result> { + self.calls + .lock() + .await + .push(format!("get_organization_projects {user_id} {org_id}")); + Ok(Default::default()) + } + + async fn get_organizations(&self, user_id: &str) -> Result> { + self.calls + .lock() + .await + .push(format!("get_organizations {user_id}")); + Ok(Default::default()) + } + + async fn transfer_project_to_user( + &self, + user_id: &str, + project_id: &str, + new_user_id: &str, + ) -> Result<()> { + self.calls.lock().await.push(format!( + "transfer_project_to_user {user_id} {project_id} {new_user_id}" + )); + + Ok(()) + } + + async fn transfer_project_to_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<()> { + self.calls.lock().await.push(format!( + "transfer_project_to_org {user_id} {project_id} {org_id}" + )); + Ok(()) + } + + async fn transfer_project_from_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<()> { + self.calls.lock().await.push(format!( + "transfer_project_from_org {user_id} {project_id} {org_id}" + )); + Ok(()) + } + + async fn add_organization_member( + &self, + admin_user: &str, + org_id: &str, + user_id: &str, + ) -> Result<()> { + self.calls.lock().await.push(format!( + "add_organization_member {admin_user} {org_id} {user_id}" + )); + Ok(()) + } + + async fn remove_organization_member( + &self, + admin_user: &str, + org_id: &str, + user_id: &str, + ) -> Result<()> { + self.calls.lock().await.push(format!( + "remove_organization_member {admin_user} {org_id} {user_id}" + )); + Ok(()) + } + + async fn get_organization_members( + &self, + user_id: &str, + org_id: &str, + ) -> Result> { + self.calls + .lock() + .await + .push(format!("get_organization_members {user_id} {org_id}")); + Ok(Default::default()) + } } diff --git a/backends/tests/integration/permit_tests.rs b/backends/tests/integration/permit_tests.rs index 410d3e144..5fd47df7d 100644 --- a/backends/tests/integration/permit_tests.rs +++ b/backends/tests/integration/permit_tests.rs @@ -8,10 +8,10 @@ mod needs_docker { }; use serial_test::serial; use shuttle_backends::client::{ - permit::{Client, Error, ResponseContent}, + permit::{Client, Error, Organization, ResponseContent}, PermissionsDal, }; - use shuttle_common::claims::AccountTier; + use shuttle_common::{claims::AccountTier, models::organization}; use shuttle_common_tests::permit_pdp::DockerInstance; use test_context::{test_context, AsyncTestContext}; use uuid::Uuid; @@ -199,4 +199,346 @@ mod needs_docker { assert!(p2.is_empty()); } + + #[test_context(Wrap)] + #[tokio::test] + #[serial] + async fn test_organizations(Wrap(client): &mut Wrap) { + let u1 = "user-o-1"; + let u2 = "user-o-2"; + client.new_user(u1).await.unwrap(); + client.new_user(u2).await.unwrap(); + + const SLEEP: u64 = 500; + + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + let org = Organization { + id: "org_123".to_string(), + display_name: "Test organization".to_string(), + }; + + let err = client.create_organization(u1, &org).await.unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Only Pro users can create organizations" + ); + + client.make_pro(u1).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + client.create_organization(u1, &org).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let o1 = client.get_organizations(u1).await.unwrap(); + + assert_eq!( + o1, + vec![organization::Response { + id: "org_123".to_string(), + display_name: "Test organization".to_string(), + is_admin: true, + }] + ); + + let err = client + .create_organization( + u1, + &Organization { + id: "org_987".to_string(), + display_name: "Second organization".to_string(), + }, + ) + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::BAD_REQUEST), + "User cannot create more than one organization" + ); + + client.create_project(u1, "proj-o-1").await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let p1 = client.get_user_projects(u1).await.unwrap(); + + assert_eq!(p1.len(), 1); + assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1"); + + client + .transfer_project_to_org(u1, "proj-o-1", "org_123") + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let p1 = client.get_user_projects(u1).await.unwrap(); + + assert_eq!(p1.len(), 1); + assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1"); + + let err = client + .get_organization_projects(u2, "org_123") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "User cannot view projects on an organization it does not belong to" + ); + + let ps = client + .get_organization_projects(u1, "org_123") + .await + .unwrap(); + assert_eq!(ps, vec!["proj-o-1"]); + + client.create_project(u2, "proj-o-2").await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let p2 = client.get_user_projects(u2).await.unwrap(); + + assert_eq!(p2.len(), 1); + assert_eq!(p2[0].resource.as_ref().unwrap().key, "proj-o-2"); + + let err = client + .transfer_project_to_org(u2, "proj-o-2", "org_123") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Cannot transfer to organization that user is not admin of" + ); + + let err = client + .transfer_project_to_org(u1, "proj-o-2", "org_123") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::NOT_FOUND), + "Cannot transfer a project that user does not own" + ); + + let err = client.delete_organization(u1, "org_123").await.unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::BAD_REQUEST), + "Cannot delete organization with projects in it" + ); + + let err = client + .transfer_project_from_org(u2, "proj-o-1", "org_123") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Cannot transfer from organization that user is not admin of" + ); + + client + .transfer_project_from_org(u1, "proj-o-1", "org_123") + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let p1 = client.get_user_projects(u1).await.unwrap(); + + assert_eq!(p1.len(), 1); + assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1"); + + let err = client.delete_organization(u2, "org_123").await.unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Cannot delete organization that user does not own" + ); + + client.delete_organization(u1, "org_123").await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let o1 = client.get_organizations(u1).await.unwrap(); + + assert_eq!(o1, vec![]); + } + + #[test_context(Wrap)] + #[tokio::test] + #[serial] + async fn test_organization_members(Wrap(client): &mut Wrap) { + let u1 = "user-om-1"; + let u2 = "user-om-2"; + let u3 = "user-om-3"; + client.new_user(u1).await.unwrap(); + client.new_user(u2).await.unwrap(); + client.new_user(u3).await.unwrap(); + + const SLEEP: u64 = 500; + + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + client.make_pro(u1).await.unwrap(); + client.make_pro(u3).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + let org = Organization { + id: "org_345".to_string(), + display_name: "Blazingly fast team".to_string(), + }; + + client.create_organization(u1, &org).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let o1 = client.get_organizations(u1).await.unwrap(); + + assert_eq!( + o1, + vec![organization::Response { + id: "org_345".to_string(), + display_name: "Blazingly fast team".to_string(), + is_admin: true, + }] + ); + + client.create_project(u1, "proj-om-1").await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + client + .transfer_project_to_org(u1, "proj-om-1", "org_345") + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + let ps = client + .get_organization_projects(u1, "org_345") + .await + .unwrap(); + assert_eq!(ps, vec!["proj-om-1"]); + + let o2 = client.get_organizations(u2).await.unwrap(); + assert_eq!(o2, vec![]); + + let err = client + .get_organization_projects(u2, "org_345") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Cannot view projects of an organization that user is not a member of" + ); + + let err = client + .add_organization_member(u1, "org_345", u2) + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::BAD_REQUEST), + "Can only add Pro users to organizations" + ); + + client.make_pro(u2).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + client + .add_organization_member(u1, "org_345", u2) + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + let o2 = client.get_organizations(u2).await.unwrap(); + assert_eq!( + o2, + vec![organization::Response { + id: "org_345".to_string(), + display_name: "Blazingly fast team".to_string(), + is_admin: false, + }] + ); + + let ps2 = client + .get_organization_projects(u2, "org_345") + .await + .unwrap(); + assert_eq!(ps2, vec!["proj-om-1"]); + + let err = client + .add_organization_member(u2, "org_345", u3) + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Only organization admin can add members" + ); + + let err = client + .get_organization_members(u3, "org_345") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Only organization members can view members" + ); + + let members = client + .get_organization_members(u1, "org_345") + .await + .unwrap(); + assert_eq!(members.len(), 2); + assert!(members.contains(&organization::MemberResponse { + id: u1.to_string(), + role: organization::MemberRole::Admin, + })); + assert!(members.contains(&organization::MemberResponse { + id: u2.to_string(), + role: organization::MemberRole::Member, + })); + + let members = client + .get_organization_members(u2, "org_345") + .await + .unwrap(); + assert_eq!(members.len(), 2); + assert!(members.contains(&organization::MemberResponse { + id: u1.to_string(), + role: organization::MemberRole::Admin, + })); + assert!(members.contains(&organization::MemberResponse { + id: u2.to_string(), + role: organization::MemberRole::Member, + })); + + let err = client + .remove_organization_member(u1, "org_345", u1) + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::BAD_REQUEST), + "User cannot remove themselves from organizations" + ); + + let err = client + .remove_organization_member(u2, "org_345", u1) + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Only organization admin can remove members" + ); + + client + .remove_organization_member(u1, "org_345", u2) + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + let o2 = client.get_organizations(u2).await.unwrap(); + assert_eq!(o2, vec![]); + + let err = client + .get_organization_projects(u2, "org_345") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Cannot view projects of an organization that user is not a member of" + ); + + let members = client + .get_organization_members(u1, "org_345") + .await + .unwrap(); + assert_eq!( + members, + vec![organization::MemberResponse { + id: u1.to_string(), + role: organization::MemberRole::Admin, + },] + ); + } } diff --git a/cargo-shuttle/src/args.rs b/cargo-shuttle/src/args.rs index 257612d4d..cf5589b7f 100644 --- a/cargo-shuttle/src/args.rs +++ b/cargo-shuttle/src/args.rs @@ -211,11 +211,11 @@ pub enum ProjectCommand { /// List all projects belonging to the calling account List { #[arg(long, default_value = "1")] - /// Which page to display + /// (deprecated) Which page to display page: u32, - #[arg(long, default_value = "10")] - /// How many projects per page to display + #[arg(long, default_value = "15")] + /// (deprecated) How many projects per page to display limit: u32, #[arg(long, default_value_t = false)] diff --git a/cargo-shuttle/src/provisioner_server.rs b/cargo-shuttle/src/provisioner_server.rs index c931e8584..74b6aa271 100644 --- a/cargo-shuttle/src/provisioner_server.rs +++ b/cargo-shuttle/src/provisioner_server.rs @@ -144,9 +144,7 @@ impl LocalProvisioner { Err(error) => { error!("Got unexpected error while inspecting docker container: {error}"); error!( - "Make sure Docker is installed and running. \ - If you're using Podman, view these instructions: \ - https://docs.rs/shuttle-runtime/latest/shuttle_runtime/#using-podman-instead-of-docker" + "Make sure Docker is installed and running. For more help: https://docs.shuttle.rs/getting-started/local-run#docker-engines" ); Err(Status::internal(error.to_string())) } diff --git a/common-tests/src/permit_pdp.rs b/common-tests/src/permit_pdp.rs index 2dcf419e9..d5b6ca2ee 100644 --- a/common-tests/src/permit_pdp.rs +++ b/common-tests/src/permit_pdp.rs @@ -15,7 +15,8 @@ impl DockerInstance { let container_name = format!("shuttle_test_permit_{}", name); let e1 = format!("PDP_CONTROL_PLANE={api_url}"); let e2 = format!("PDP_API_KEY={api_key}"); - let env = [e1.as_str(), e2.as_str()]; + let e3 = "PDP_OPA_CLIENT_QUERY_TIMEOUT=10"; + let env = [e1.as_str(), e2.as_str(), e3]; let port = "7000"; let image = "docker.io/permitio/pdp-v2:0.2.37"; let is_ready_cmd = vec![ diff --git a/common/Cargo.toml b/common/Cargo.toml index ba5a8ff45..1d96b009e 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -44,6 +44,7 @@ wiremock = { workspace = true, optional = true } [features] axum = ["dep:axum"] claims = [ + "axum", "bytes", "chrono/clock", "headers", diff --git a/common/src/claims.rs b/common/src/claims.rs index a27c6e4ec..89fe09682 100644 --- a/common/src/claims.rs +++ b/common/src/claims.rs @@ -5,10 +5,11 @@ use std::{ task::{Context, Poll}, }; +use axum::extract::FromRequestParts; use bytes::Bytes; use chrono::{Duration, Utc}; use headers::{Authorization, HeaderMapExt}; -use http::{Request, StatusCode}; +use http::{request::Parts, Request, StatusCode}; use http_body::combinators::UnsyncBoxBody; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use opentelemetry::global; @@ -332,6 +333,26 @@ impl Claim { } } +/// Extract the claim from the request and fail with unauthorized if the claim doesn't exist +#[axum::async_trait] +impl FromRequestParts for Claim { + type Rejection = StatusCode; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let claim = parts + .extensions + .get::() + .ok_or(StatusCode::UNAUTHORIZED)?; + + // Record current account name for tracing purposes + Span::current().record("account.user_id", &claim.sub); + + trace!(?claim, "got user"); + + Ok(claim.clone()) + } +} + // Future for layers that just return the inner response #[pin_project] pub struct ResponseFuture(#[pin] pub F); diff --git a/common/src/models/error.rs b/common/src/models/error.rs index 7cc3eb98e..de07c28da 100644 --- a/common/src/models/error.rs +++ b/common/src/models/error.rs @@ -98,6 +98,8 @@ pub enum ErrorKind { DeleteProjectFailed, #[error("Our server is at capacity and cannot serve your request at this time. Please try again in a few minutes.")] CapacityLimit, + #[error("{0:?}")] + InvalidOrganizationName(InvalidOrganizationName), } impl From for ApiError { @@ -130,6 +132,7 @@ impl From for ApiError { ErrorKind::NotReady => StatusCode::INTERNAL_SERVER_ERROR, ErrorKind::DeleteProjectFailed => StatusCode::INTERNAL_SERVER_ERROR, ErrorKind::CapacityLimit => StatusCode::SERVICE_UNAVAILABLE, + ErrorKind::InvalidOrganizationName(_) => StatusCode::BAD_REQUEST, }; Self { message: kind.to_string(), @@ -190,3 +193,7 @@ impl From for ApiError { 6. not be a reserved word." )] pub struct InvalidProjectName; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +#[error("Invalid organization name. Must not be more than 30 characters long.")] +pub struct InvalidOrganizationName; diff --git a/common/src/models/mod.rs b/common/src/models/mod.rs index 1d687f638..05a881250 100644 --- a/common/src/models/mod.rs +++ b/common/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod deployment; pub mod error; +pub mod organization; pub mod project; pub mod resource; pub mod service; diff --git a/common/src/models/organization.rs b/common/src/models/organization.rs new file mode 100644 index 000000000..d210634e1 --- /dev/null +++ b/common/src/models/organization.rs @@ -0,0 +1,36 @@ +use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use super::user::UserId; + +/// Minimal organization information +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Response { + /// Organization ID + pub id: String, + + /// Name used for display purposes + pub display_name: String, + + /// Is this user an admin of the organization + pub is_admin: bool, +} + +/// Member of an organization +#[derive(Debug, Serialize, Deserialize, PartialEq)] +pub struct MemberResponse { + /// User ID + pub id: UserId, + + /// Role of the user in the organization + pub role: MemberRole, +} + +/// Role of a user in an organization +#[derive(Debug, Serialize, Deserialize, PartialEq, Display, EnumString)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum MemberRole { + Admin, + Member, +} diff --git a/common/src/templates.rs b/common/src/templates.rs index 7a93032c8..a4205597c 100644 --- a/common/src/templates.rs +++ b/common/src/templates.rs @@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize}; pub struct TemplatesSchema { /// Version of this schema pub version: u32, + /// Mapping of tag names to logo URLs + pub logos: HashMap, /// Very basic templates, typically Hello World pub starters: HashMap, /// Non-starter templates diff --git a/deployer/src/deployment/queue.rs b/deployer/src/deployment/queue.rs index 582e47c40..f4dd0ffb5 100644 --- a/deployer/src/deployment/queue.rs +++ b/deployer/src/deployment/queue.rs @@ -393,7 +393,15 @@ async fn copy_executable( new_filename: &Uuid, ) -> Result<()> { fs::create_dir_all(to_directory).await?; - fs::copy(executable_path, to_directory.join(new_filename.to_string())).await?; + fs::copy(executable_path, to_directory.join(new_filename.to_string())) + .await + .map_err(|e| { + error!( + "Did not find built binary at {}. Make sure the wanted binary target has the same name as your crate.", + executable_path.display() + ); + e + })?; Ok(()) } diff --git a/examples b/examples index a24f88d38..cfad214cb 160000 --- a/examples +++ b/examples @@ -1 +1 @@ -Subproject commit a24f88d38e4f8ce82dc6b052e252512c7ba9cb40 +Subproject commit cfad214cbab765af2c6fb30c538cb8859f0b8c6b diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index f6abf863f..0ed7ecaef 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -21,14 +21,15 @@ use serde::{Deserialize, Serialize}; use shuttle_backends::auth::{AuthPublicKey, JwtAuthenticationLayer, ScopedLayer}; use shuttle_backends::axum::CustomErrorPath; use shuttle_backends::cache::CacheManager; +use shuttle_backends::client::permit::Organization; use shuttle_backends::metrics::{Metrics, TraceLayer}; use shuttle_backends::project_name::ProjectName; use shuttle_backends::request_span; use shuttle_backends::ClaimExt; -use shuttle_common::claims::{Scope, EXP_MINUTES}; -use shuttle_common::models::error::ErrorKind; -use shuttle_common::models::service; +use shuttle_common::claims::{Claim, Scope, EXP_MINUTES}; +use shuttle_common::models::error::{ErrorKind, InvalidOrganizationName}; use shuttle_common::models::{admin::ProjectResponse, project, stats}; +use shuttle_common::models::{organization, service}; use shuttle_common::{deployment, VersionInfo}; use shuttle_proto::provisioner::provisioner_client::ProvisionerClient; use shuttle_proto::provisioner::Ping; @@ -36,7 +37,7 @@ use tokio::sync::mpsc::Sender; use tokio::sync::{Mutex, MutexGuard}; use tower::ServiceBuilder; use tower_http::cors::CorsLayer; -use tracing::{error, field, instrument, trace}; +use tracing::{error, field, instrument, trace, Span}; use ttl_cache::TtlCache; use ulid::Ulid; use uuid::Uuid; @@ -47,7 +48,7 @@ use x509_parser::time::ASN1Time; use crate::acme::{AccountWrapper, AcmeClient, CustomDomain}; use crate::api::tracing::project_name_tracing_layer; -use crate::auth::{ScopedUser, User}; +use crate::auth::ScopedUser; use crate::service::{ContainerSettings, GatewayService}; use crate::task::{self, BoxedTask}; use crate::tls::{GatewayCertResolver, RENEWAL_VALIDITY_THRESHOLD_IN_DAYS}; @@ -131,12 +132,12 @@ async fn check_project_name( } async fn get_projects_list( State(RouterState { service, .. }): State, - User { id, .. }: User, + Claim { sub, .. }: Claim, ) -> Result>, Error> { let mut projects = vec![]; for p in service .permit_client - .get_user_projects(&id) + .get_user_projects(&sub) .await .map_err(|_| Error::from(ErrorKind::Internal))? { @@ -163,7 +164,7 @@ async fn create_project( State(RouterState { service, sender, .. }): State, - User { id, claim, .. }: User, + claim: Claim, CustomErrorPath(project_name): CustomErrorPath, AxumJson(config): AxumJson, ) -> Result, Error> { @@ -172,7 +173,7 @@ async fn create_project( // Check that the user is within their project limits. let can_create_project = claim.can_create_project( service - .get_project_count(&id) + .get_project_count(&claim.sub) .await? .saturating_sub(is_cch_project as u32), ); @@ -184,7 +185,7 @@ async fn create_project( let project = service .create_project( project_name.clone(), - &id, + &claim.sub, claim.is_admin(), can_create_project, if is_cch_project { @@ -398,7 +399,7 @@ async fn override_create_service( scoped_user: ScopedUser, req: Request, ) -> Result, Error> { - let user_id = scoped_user.user.id.clone(); + let user_id = scoped_user.claim.sub.clone(); let posthog_client = state.posthog_client.clone(); tokio::spawn(async move { let event = async_posthog::Event::new("shuttle_api_start_deployment", &user_id); @@ -460,9 +461,9 @@ async fn route_project( let project_name = scoped_user.scope; let is_cch_project = project_name.is_cch_project(); - if !scoped_user.user.claim.is_admin() { + if !scoped_user.claim.is_admin() { service - .has_capacity(is_cch_project, &scoped_user.user.claim.tier) + .has_capacity(is_cch_project, &scoped_user.claim.tier) .await?; } @@ -471,10 +472,159 @@ async fn route_project( .await? .0; service - .route(&project.state, &project_name, &scoped_user.user.id, req) + .route(&project.state, &project_name, &scoped_user.claim.sub, req) .await } +#[instrument(skip_all)] +async fn get_organizations( + State(RouterState { service, .. }): State, + Claim { sub, .. }: Claim, +) -> Result>, Error> { + let orgs = service.permit_client.get_organizations(&sub).await?; + + Ok(AxumJson(orgs)) +} + +#[instrument(skip_all, fields(shuttle.organization.name = %organization_name, shuttle.organization.id = field::Empty))] +async fn create_organization( + State(RouterState { service, .. }): State, + CustomErrorPath(organization_name): CustomErrorPath, + Claim { sub, .. }: Claim, +) -> Result { + if organization_name.chars().count() > 30 { + return Err(Error::from_kind(ErrorKind::InvalidOrganizationName( + InvalidOrganizationName, + ))); + } + + let org = Organization { + id: format!("org_{}", Ulid::new()), + display_name: organization_name.clone(), + }; + + service + .permit_client + .create_organization(&sub, &org) + .await?; + + Span::current().record("shuttle.organization.id", &org.id); + + Ok("Organization created".to_string()) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id))] +async fn get_organization_projects( + State(RouterState { service, .. }): State, + CustomErrorPath(organization_id): CustomErrorPath, + Claim { sub, .. }: Claim, +) -> Result>, Error> { + let project_ids = service + .permit_client + .get_organization_projects(&sub, &organization_id) + .await?; + + let mut projects = Vec::with_capacity(project_ids.len()); + + for project_id in project_ids { + let project = service.find_project_by_id(&project_id).await?; + let idle_minutes = project.state.idle_minutes(); + + projects.push(project::Response { + id: project.id, + name: project.name, + state: project.state.into(), + idle_minutes, + }); + } + + Ok(AxumJson(projects)) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id))] +async fn delete_organization( + State(RouterState { service, .. }): State, + CustomErrorPath(organization_id): CustomErrorPath, + Claim { sub, .. }: Claim, +) -> Result { + service + .permit_client + .delete_organization(&sub, &organization_id) + .await?; + + Ok("Organization deleted".to_string()) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id, shuttle.project.id = %project_id))] +async fn transfer_project_to_organization( + State(RouterState { service, .. }): State, + CustomErrorPath((organization_id, project_id)): CustomErrorPath<(String, String)>, + Claim { sub, .. }: Claim, +) -> Result { + service + .permit_client + .transfer_project_to_org(&sub, &project_id, &organization_id) + .await?; + + Ok("Project transfered".to_string()) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id, shuttle.project.id = %project_id))] +async fn transfer_project_from_organization( + State(RouterState { service, .. }): State, + CustomErrorPath((organization_id, project_id)): CustomErrorPath<(String, String)>, + Claim { sub, .. }: Claim, +) -> Result { + service + .permit_client + .transfer_project_from_org(&sub, &project_id, &organization_id) + .await?; + + Ok("Project transfered".to_string()) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id))] +async fn get_organization_members( + State(RouterState { service, .. }): State, + CustomErrorPath(organization_id): CustomErrorPath, + Claim { sub, .. }: Claim, +) -> Result>, Error> { + let members = service + .permit_client + .get_organization_members(&sub, &organization_id) + .await?; + + Ok(AxumJson(members)) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id))] +async fn add_member_to_organization( + State(RouterState { service, .. }): State, + CustomErrorPath((organization_id, user_id)): CustomErrorPath<(String, String)>, + Claim { sub, .. }: Claim, +) -> Result { + service + .permit_client + .add_organization_member(&sub, &organization_id, &user_id) + .await?; + + Ok("Member added".to_string()) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id))] +async fn remove_member_from_organization( + State(RouterState { service, .. }): State, + CustomErrorPath((organization_id, user_id)): CustomErrorPath<(String, String)>, + Claim { sub, .. }: Claim, +) -> Result { + service + .permit_client + .remove_organization_member(&sub, &organization_id, &user_id) + .await?; + + Ok("Member removed".to_string()) +} + async fn get_status( State(RouterState { sender, service, .. @@ -808,6 +958,17 @@ async fn get_projects( Ok(AxumJson(projects)) } +async fn change_project_owner( + State(RouterState { service, .. }): State, + Path((project_name, new_user_id)): Path<(String, String)>, +) -> Result<(), Error> { + service + .update_project_owner(&project_name, &new_user_id) + .await?; + + Ok(()) +} + #[derive(Clone)] pub(crate) struct RouterState { pub service: Arc, @@ -902,6 +1063,10 @@ impl ApiBuilder { pub fn with_default_routes(mut self) -> Self { let admin_routes = Router::new() .route("/projects", get(get_projects)) + .route( + "/projects/change-owner/:project_name/:new_user_id", + get(change_project_owner), + ) .route("/revive", post(revive_projects)) .route("/destroy", post(destroy_projects)) .route("/idle-cch", post(idle_cch_projects)) @@ -932,10 +1097,26 @@ impl ApiBuilder { .route("/projects/:project_name/*any", any(route_project)) .route_layer(middleware::from_fn(project_name_tracing_layer)); + let organization_routes = Router::new() + .route("/", get(get_organizations)) + .route("/name/:organization_name", post(create_organization)) + .route("/:organization_id", delete(delete_organization)) + .route("/:organization_id/projects", get(get_organization_projects)) + .route( + "/:organization_id/projects/:project_id", + post(transfer_project_to_organization).delete(transfer_project_from_organization), + ) + .route("/:organization_id/members", get(get_organization_members)) + .route( + "/:organization_id/members/:user_id", + post(add_member_to_organization).delete(remove_member_from_organization), + ); + self.router = self .router .route("/", get(get_status)) .merge(project_routes) + .nest("/organizations", organization_routes) .route( "/versions", get(|| async { diff --git a/gateway/src/api/project_caller.rs b/gateway/src/api/project_caller.rs index cddf214ed..ff1676e82 100644 --- a/gateway/src/api/project_caller.rs +++ b/gateway/src/api/project_caller.rs @@ -43,7 +43,7 @@ impl ProjectCaller { Ok(Self { project: project.state, project_name, - user_id: scoped_user.user.id, + user_id: scoped_user.claim.sub, service, headers: headers.clone(), }) diff --git a/gateway/src/auth.rs b/gateway/src/auth.rs index cb3dc6b4b..b2bc72d65 100644 --- a/gateway/src/auth.rs +++ b/gateway/src/auth.rs @@ -1,55 +1,14 @@ -use std::fmt::Debug; - use axum::extract::{FromRef, FromRequestParts, Path}; use axum::http::request::Parts; -use serde::{Deserialize, Serialize}; use shuttle_backends::project_name::ProjectName; use shuttle_backends::ClaimExt; use shuttle_common::claims::Claim; use shuttle_common::models::error::InvalidProjectName; -use shuttle_common::models::user::UserId; -use tracing::{error, trace, Span}; +use tracing::error; use crate::api::latest::RouterState; use crate::{Error, ErrorKind}; -/// A wrapper to enrich a token with user details -/// -/// The `FromRequest` impl consumes the API claim and enriches it with project -/// details. Generally you want to use [`ScopedUser`] instead to ensure the request -/// is valid against the user's owned resources. -#[derive(Clone, Deserialize, PartialEq, Eq, Serialize, Debug)] -pub struct User { - pub claim: Claim, - pub id: UserId, -} - -#[async_trait] -impl FromRequestParts for User -where - S: Send + Sync, - RouterState: FromRef, -{ - type Rejection = Error; - - async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let claim = parts.extensions.get::().ok_or(ErrorKind::Internal)?; - let user_id = claim.sub.clone(); - - // Record current account name for tracing purposes - Span::current().record("account.user_id", &user_id); - - let user = User { - claim: claim.clone(), - id: user_id, - }; - - trace!(?user, "got user"); - - Ok(user) - } -} - /// A wrapper for a guard that validates a user's API token *and* /// scopes the request to a project they own. /// @@ -57,7 +16,7 @@ where /// by [`ScopedUser::name`]. #[derive(Clone)] pub struct ScopedUser { - pub user: User, + pub claim: Claim, pub scope: ProjectName, } @@ -70,7 +29,9 @@ where type Rejection = Error; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let user = User::from_request_parts(parts, state).await?; + let claim = Claim::from_request_parts(parts, state) + .await + .map_err(|_| ErrorKind::Unauthorized)?; let scope = match Path::::from_request_parts(parts, state).await { Ok(Path(p)) => p, @@ -82,12 +43,12 @@ where let RouterState { service, .. } = RouterState::from_ref(state); - let allowed = user.claim.is_admin() - || user.claim.is_deployer() + let allowed = claim.is_admin() + || claim.is_deployer() || service .permit_client .allowed( - &user.id, + &claim.sub, &service.find_project_by_name(&scope).await?.id, "develop", // TODO: make this configurable per endpoint? ) @@ -98,7 +59,7 @@ where })?; if allowed { - Ok(Self { user, scope }) + Ok(Self { claim, scope }) } else { Err(Error::from(ErrorKind::ProjectNotFound(scope.to_string()))) } diff --git a/gateway/src/lib.rs b/gateway/src/lib.rs index d8412c912..cbdd4cc7f 100644 --- a/gateway/src/lib.rs +++ b/gateway/src/lib.rs @@ -17,6 +17,7 @@ use hyper::client::HttpConnector; use hyper::Client; use once_cell::sync::Lazy; use service::ContainerSettings; +use shuttle_backends::client::permit; use shuttle_backends::project_name::ProjectName; use shuttle_common::models::error::{ApiError, ErrorKind}; use shuttle_common::models::user::UserId; @@ -110,6 +111,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: permit::Error) -> Self { + Self::source(ErrorKind::Internal, error) + } +} + impl IntoResponse for Error { fn into_response(self) -> Response { let error: ApiError = self.kind.clone().into(); diff --git a/gateway/src/service.rs b/gateway/src/service.rs index ae7aa1587..5fa50752f 100644 --- a/gateway/src/service.rs +++ b/gateway/src/service.rs @@ -32,7 +32,7 @@ use sqlx::error::DatabaseError; use sqlx::migrate::Migrator; use sqlx::sqlite::SqlitePool; use sqlx::types::Json as SqlxJson; -use sqlx::{query, Error as SqlxError, QueryBuilder, Row}; +use sqlx::{query, query_as, Error as SqlxError, QueryBuilder, Row}; use tokio::sync::mpsc::Sender; use tokio::time::timeout; use tonic::codegen::tokio_stream::StreamExt; @@ -492,6 +492,33 @@ impl GatewayService { Ok(()) } + pub async fn update_project_owner( + &self, + project_name: &str, + new_user_id: &str, + ) -> Result<(), Error> { + let mut tr = self.db.begin().await?; + let (project_id, user_id) = query_as::<_, (String, String)>( + "SELECT project_id, user_id FROM projects WHERE project_name = ?1", + ) + .bind(project_name) + .fetch_one(&mut *tr) + .await?; + query("UPDATE projects SET user_id = ?1 WHERE project_name = ?2") + .bind(new_user_id) + .bind(project_name) + .execute(&mut *tr) + .await?; + + self.permit_client + .transfer_project_to_user(&user_id, &project_id, new_user_id) + .await?; + + tr.commit().await?; + + Ok(()) + } + pub async fn user_id_from_project(&self, project_name: &ProjectName) -> Result { query("SELECT user_id FROM projects WHERE project_name = ?1") .bind(project_name) @@ -678,8 +705,7 @@ impl GatewayService { self.permit_client .create_project(user_id, &project_id.to_string()) - .await - .map_err(|_| Error::from(ErrorKind::Internal))?; + .await?; transaction.commit().await?; @@ -711,10 +737,7 @@ impl GatewayService { .execute(&mut *transaction) .await?; - self.permit_client - .delete_project(&project_id) - .await - .map_err(|_| Error::from(ErrorKind::Internal))?; + self.permit_client.delete_project(&project_id).await?; transaction.commit().await?; diff --git a/services/shuttle-poem/Cargo.toml b/services/shuttle-poem/Cargo.toml index 265289b90..46b427a11 100644 --- a/services/shuttle-poem/Cargo.toml +++ b/services/shuttle-poem/Cargo.toml @@ -10,5 +10,5 @@ keywords = ["shuttle-service", "poem"] [workspace] [dependencies] -poem = "2.0.0" +poem = "3.0.0" shuttle-runtime = { path = "../../runtime", version = "0.43.0", default-features = false } diff --git a/shell.nix b/shell.nix index 8297f8f3b..ac00663b8 100644 --- a/shell.nix +++ b/shell.nix @@ -1,7 +1,7 @@ let moz_overlay = import (builtins.fetchTarball https://github.com/mozilla/nixpkgs-mozilla/archive/master.tar.gz); # Pin to stable from https://status.nixos.org/ - nixpkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/596a8e828c5dfa504f91918d0fa4152db3ab5502.tar.gz") { overlays = [ moz_overlay ]; }; + nixpkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/219951b495fc2eac67b1456824cc1ec1fd2ee659.tar.gz") { overlays = [ moz_overlay ]; }; in with nixpkgs; stdenv.mkDerivation { @@ -11,11 +11,10 @@ in openssl ]; buildInputs = with nixpkgs; [ - ((rustChannelOf{ channel = "1.75.0"; }).rust.override { + ((rustChannelOf{ channel = "1.77.1"; }).rust.override { extensions = ["rust-src"]; }) cargo-watch - terraform awscli2 websocat protobuf