From d26e6c838ef478b513401e0c9fae77cfb2677840 Mon Sep 17 00:00:00 2001 From: jonaro00 <54029719+jonaro00@users.noreply.github.com> Date: Fri, 5 Apr 2024 14:29:27 +0200 Subject: [PATCH 1/4] feat(admin): change project owner (#1725) * wip: change project owner * feat(admin): change project owner * fmt * fmt * feat: transfer project to user * fix: route name, query * fix: admin error on non 2xx * fmt --- Cargo.lock | 1 + admin/Cargo.toml | 1 + admin/src/args.rs | 6 ++ admin/src/client.rs | 29 +++++-- admin/src/config.rs | 4 + admin/src/main.rs | 10 +++ backends/src/client/permit.rs | 121 ++++++++++++++++------------- backends/src/test_utils/gateway.rs | 49 +++++++----- gateway/src/api/latest.rs | 17 +++- gateway/src/service.rs | 29 ++++++- 10 files changed, 183 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae4f01ee0..27ead5f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5167,6 +5167,7 @@ name = "shuttle-admin" version = "0.43.0" dependencies = [ "anyhow", + "bytes", "clap", "dirs", "reqwest", diff --git a/admin/Cargo.toml b/admin/Cargo.toml index 7b9bed567..861e93f1a 100644 --- a/admin/Cargo.toml +++ b/admin/Cargo.toml @@ -8,6 +8,7 @@ publish = false shuttle-common = { workspace = true, features = ["models"] } 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..d4ecfc9db 100644 --- a/admin/src/main.rs +++ b/admin/src/main.rs @@ -165,6 +165,16 @@ async fn main() { client.idle_cch().await.expect("cch projects to be idled"); "Idled CCH projects".to_string() } + Command::ChangeProjectOwner { + project_name, + new_user_id, + } => { + client + .change_project_owner(&project_name, &new_user_id) + .await + .unwrap(); + format!("Changed project owner: {project_name} -> {new_user_id}") + } }; println!("{res}"); diff --git a/backends/src/client/permit.rs b/backends/src/client/permit.rs index f3c84cbbc..e4cb7eb66 100644 --- a/backends/src/client/permit.rs +++ b/backends/src/client/permit.rs @@ -36,48 +36,52 @@ 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 /// Creates an Organization resource and assigns the user as admin for the organization - async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error>; + 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<(), Error>; + 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, Error>; + 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( + 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, - org_id: &str, - ) -> Result, Error>; + project_id: &str, + new_user_id: &str, + ) -> Result<()>; - /// Transfers a project from a users to an organization + /// 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<(), Error>; + ) -> Result<()>; /// Transfers a project from an organization to a user async fn transfer_project_from_org( @@ -85,14 +89,14 @@ pub trait PermissionsDal { user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error>; + ) -> 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 @@ -163,22 +167,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| { @@ -192,7 +196,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 @@ -205,7 +209,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, @@ -236,7 +240,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, @@ -246,7 +250,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 { @@ -266,7 +270,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, @@ -292,7 +296,7 @@ impl PermissionsDal for Client { Ok(res.allow.unwrap_or_default()) } - async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error> { + 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, @@ -341,7 +345,7 @@ impl PermissionsDal for Client { Ok(()) } - async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error> { + 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, @@ -369,11 +373,7 @@ impl PermissionsDal for Client { .await?) } - async fn get_organization_projects( - &self, - user_id: &str, - org_id: &str, - ) -> Result, Error> { + 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, @@ -407,7 +407,7 @@ impl PermissionsDal for Client { Ok(projects) } - async fn get_organizations(&self, user_id: &str) -> Result, Error> { + async fn get_organizations(&self, user_id: &str) -> Result> { let perms = get_user_permissions_user_permissions_post( &self.pdp, UserPermissionsQuery { @@ -446,12 +446,27 @@ impl PermissionsDal for Client { 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<(), Error> { + ) -> Result<()> { if !self.allowed_org(user_id, org_id, "manage").await? { return Err(Error::ResponseError(ResponseContent { status: StatusCode::FORBIDDEN, @@ -478,7 +493,7 @@ impl PermissionsDal for Client { user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error> { + ) -> Result<()> { if !self.allowed_org(user_id, org_id, "manage").await? { return Err(Error::ResponseError(ResponseContent { status: StatusCode::FORBIDDEN, @@ -503,7 +518,7 @@ impl PermissionsDal for Client { // Helpers for trait methods impl Client { - // pub async fn get_organization_members(&self, org_name: &str) -> Result, Error> { + // pub async fn get_organization_members(&self, org_name: &str) -> Result> { // self.api // .get( // &format!( @@ -519,7 +534,7 @@ impl Client { // &self, // org_name: &str, // user_id: &str, - // ) -> Result<(), Error> { + // ) -> Result<()> { // self.api // .post( // &format!("{}/role_assignments", self.facts), @@ -538,7 +553,7 @@ impl Client { // &self, // org_name: &str, // user_id: &str, - // ) -> Result<(), Error> { + // ) -> Result<()> { // self.api // .delete( // &format!("{}/role_assignments", self.facts), @@ -553,7 +568,7 @@ impl Client { // .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, @@ -566,7 +581,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, @@ -583,7 +598,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, @@ -605,7 +620,7 @@ impl Client { user_id: &str, resource_instance: String, role: &str, - ) -> Result<(), Error> { + ) -> Result<()> { assign_role( &self.api, &self.proj_id, @@ -627,7 +642,7 @@ impl Client { user_id: &str, resource_instance: String, role: &str, - ) -> Result<(), Error> { + ) -> Result<()> { unassign_role( &self.api, &self.proj_id, @@ -644,7 +659,7 @@ impl Client { Ok(()) } - async fn allowed_org(&self, user_id: &str, org_id: &str, action: &str) -> Result { + 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, @@ -670,12 +685,7 @@ impl Client { Ok(res.allow.unwrap_or_default()) } - async fn assign_relationship( - &self, - subject: String, - role: &str, - object: String, - ) -> Result<(), Error> { + async fn assign_relationship(&self, subject: String, role: &str, object: String) -> Result<()> { create_relationship_tuple( &self.api, &self.proj_id, @@ -697,7 +707,7 @@ impl Client { subject: String, role: &str, object: String, - ) -> Result<(), Error> { + ) -> Result<()> { delete_relationship_tuple( &self.api, &self.proj_id, @@ -713,7 +723,7 @@ impl Client { Ok(()) } - pub async fn sync_pdp(&self) -> Result<(), Error> { + 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?; @@ -736,7 +746,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, @@ -786,6 +796,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/test_utils/gateway.rs b/backends/src/test_utils/gateway.rs index 192ba916f..b4b18a08d 100644 --- a/backends/src/test_utils/gateway.rs +++ b/backends/src/test_utils/gateway.rs @@ -13,7 +13,7 @@ use wiremock::{ }; use crate::client::{ - permit::{Error, Organization}, + permit::{Organization, Result}, PermissionsDal, }; @@ -101,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 @@ -114,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 @@ -132,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 @@ -140,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 @@ -148,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 @@ -156,7 +156,7 @@ 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 @@ -164,7 +164,7 @@ impl PermissionsDal for PermissionsMock { Ok(true) } - async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error> { + 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 @@ -172,7 +172,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error> { + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<()> { self.calls .lock() .await @@ -180,11 +180,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn get_organization_projects( - &self, - user_id: &str, - org_id: &str, - ) -> Result, Error> { + async fn get_organization_projects(&self, user_id: &str, org_id: &str) -> Result> { self.calls .lock() .await @@ -192,7 +188,7 @@ impl PermissionsDal for PermissionsMock { Ok(Default::default()) } - async fn get_organizations(&self, user_id: &str) -> Result, Error> { + async fn get_organizations(&self, user_id: &str) -> Result> { self.calls .lock() .await @@ -200,12 +196,25 @@ impl PermissionsDal for PermissionsMock { 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<(), Error> { + ) -> Result<()> { self.calls.lock().await.push(format!( "transfer_project_to_org {user_id} {project_id} {org_id}" )); @@ -217,7 +226,7 @@ impl PermissionsDal for PermissionsMock { user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error> { + ) -> Result<()> { self.calls.lock().await.push(format!( "transfer_project_from_org {user_id} {project_id} {org_id}" )); diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index 4d8fd002d..84733519c 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -916,6 +916,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, @@ -1010,6 +1021,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)) @@ -1042,7 +1057,7 @@ impl ApiBuilder { let organization_routes = Router::new() .route("/", get(get_organizations)) - .route("/:organization_name", post(create_organization)) + .route("/name/:organization_name", post(create_organization)) .route("/:organization_id", delete(delete_organization)) .route("/:organization_id/projects", get(get_organization_projects)) .route( diff --git a/gateway/src/service.rs b/gateway/src/service.rs index 1fc546d4b..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) From 639b5ae78e5704cdd5e5ba9feba31d9be8e368ca Mon Sep 17 00:00:00 2001 From: Sourab Pramanik Date: Mon, 8 Apr 2024 13:52:56 +0530 Subject: [PATCH 2/4] feat: bump poem version (#1724) * feat: bump poem version * chore: bump examples * chore: bump examples --------- Co-authored-by: jonaro00 <54029719+jonaro00@users.noreply.github.com> --- examples | 2 +- services/shuttle-poem/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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 } From 37d5f6f6a7bffd9ab9870fad3cfb6c6d174d0ba1 Mon Sep 17 00:00:00 2001 From: Pieter Date: Mon, 8 Apr 2024 09:27:04 +0100 Subject: [PATCH 3/4] feat: org members (#1728) * feat: org members to permit * feat: org member routes to gw * misc: touch ups * refactor: functional loops * misc: missed merge update --- Cargo.lock | 2 +- backends/Cargo.toml | 2 +- backends/src/client/permit.rs | 190 ++++++++++++++------ backends/src/test_utils/gateway.rs | 36 ++++ backends/tests/integration/permit_tests.rs | 192 +++++++++++++++++++++ common/src/models/organization.rs | 22 +++ gateway/src/api/latest.rs | 47 +++++ 7 files changed, 432 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 27ead5f6e..6fcdcc328 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", diff --git a/backends/Cargo.toml b/backends/Cargo.toml index 79fc79452..1cf05e2b4 100644 --- a/backends/Cargo.toml +++ b/backends/Cargo.toml @@ -24,7 +24,7 @@ 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"] } diff --git a/backends/src/client/permit.rs b/backends/src/client/permit.rs index e4cb7eb66..850811883 100644 --- a/backends/src/client/permit.rs +++ b/backends/src/client/permit.rs @@ -1,4 +1,7 @@ -use std::fmt::{Debug, Display}; +use std::{ + fmt::{Debug, Display}, + str::FromStr, +}; use async_trait::async_trait; use http::StatusCode; @@ -8,7 +11,7 @@ use permit_client_rs::{ 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, }, @@ -91,6 +94,29 @@ pub trait PermissionsDal { 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 @@ -398,11 +424,10 @@ impl PermissionsDal for Client { ) .await?; - let mut projects = Vec::with_capacity(relationships.len()); - - for rel in relationships { - projects.push(rel.object_details.expect("to have object details").key); - } + let projects = relationships + .into_iter() + .map(|rel| rel.object_details.expect("to have object details").key) + .collect(); Ok(projects) } @@ -514,60 +539,111 @@ impl PermissionsDal for Client { 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 { - // pub async fn get_organization_members(&self, org_name: &str) -> Result> { - // 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<()> { - // 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<()> { - // 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 { Ok(create_user( &self.api, diff --git a/backends/src/test_utils/gateway.rs b/backends/src/test_utils/gateway.rs index b4b18a08d..7257b4e00 100644 --- a/backends/src/test_utils/gateway.rs +++ b/backends/src/test_utils/gateway.rs @@ -232,4 +232,40 @@ impl PermissionsDal for PermissionsMock { )); 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 c1f513ec9..5fd47df7d 100644 --- a/backends/tests/integration/permit_tests.rs +++ b/backends/tests/integration/permit_tests.rs @@ -349,4 +349,196 @@ mod needs_docker { 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/common/src/models/organization.rs b/common/src/models/organization.rs index 2ade0510f..d210634e1 100644 --- a/common/src/models/organization.rs +++ b/common/src/models/organization.rs @@ -1,4 +1,7 @@ use serde::{Deserialize, Serialize}; +use strum::{Display, EnumString}; + +use super::user::UserId; /// Minimal organization information #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] @@ -12,3 +15,22 @@ pub struct Response { /// 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/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index 84733519c..0ed7ecaef 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -583,6 +583,48 @@ async fn transfer_project_from_organization( 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, .. @@ -1063,6 +1105,11 @@ impl ApiBuilder { .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 From 1922b0ee2bb77a78504d746e91cafa493c1a55f0 Mon Sep 17 00:00:00 2001 From: jonaro00 <54029719+jonaro00@users.noreply.github.com> Date: Tue, 9 Apr 2024 11:48:34 +0200 Subject: [PATCH 4/4] fix: various fixes and corrections (#1729) * nit: ignore private folder * fix: docs link * feat: templates logos * fix: improved deployer error message in multi binary builds * chore: mark project list paging as deprecated * fix: admin project names, project name severity level --- .gitignore | 2 + Cargo.lock | 1 + admin/Cargo.toml | 1 + admin/src/main.rs | 151 ++++++------------------ backends/Cargo.toml | 1 + backends/src/project_name.rs | 2 +- cargo-shuttle/src/args.rs | 6 +- cargo-shuttle/src/provisioner_server.rs | 4 +- common/src/templates.rs | 2 + deployer/src/deployment/queue.rs | 10 +- 10 files changed, 60 insertions(+), 120 deletions(-) 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 6fcdcc328..4508ec071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5173,6 +5173,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "shuttle-backends", "shuttle-common", "tokio", "toml", diff --git a/admin/Cargo.toml b/admin/Cargo.toml index 861e93f1a..2b7e1b7c8 100644 --- a/admin/Cargo.toml +++ b/admin/Cargo.toml @@ -6,6 +6,7 @@ publish = false [dependencies] shuttle-common = { workspace = true, features = ["models"] } +shuttle-backends = { workspace = true } anyhow = { workspace = true } bytes = { workspace = true } diff --git a/admin/src/main.rs b/admin/src/main.rs index d4ecfc9db..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,14 +85,14 @@ 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, @@ -173,9 +102,7 @@ async fn main() { .change_project_owner(&project_name, &new_user_id) .await .unwrap(); - format!("Changed project owner: {project_name} -> {new_user_id}") + println!("Changed project owner: {project_name} -> {new_user_id}") } }; - - println!("{res}"); } diff --git a/backends/Cargo.toml b/backends/Cargo.toml index 1cf05e2b4..d2441ed81 100644 --- a/backends/Cargo.toml +++ b/backends/Cargo.toml @@ -29,6 +29,7 @@ permit-pdp-client-rs = { git = "https://github.com/shuttle-hq/permit-pdp-client- 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/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/cargo-shuttle/src/args.rs b/cargo-shuttle/src/args.rs index 264436c56..705583507 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/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(()) }