diff --git a/.circleci/config.yml b/.circleci/config.yml index f3d901d76..a9bed31f9 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -649,6 +649,10 @@ workflows: - shuttle-common - test-workspace-member-with-integration: name: << matrix.crate >> + filters: + branches: + ignore: + - /pull\/.*/ matrix: alias: test-workspace-member-with-integration-medium parameters: @@ -680,6 +684,10 @@ workflows: - shuttle-deployer - test-workspace-member-with-integration-docker: name: << matrix.crate >> with docker + filters: + branches: + ignore: + - /pull\/.*/ matrix: alias: test-workspace-member-with-integration-docker-medium parameters: @@ -687,6 +695,7 @@ workflows: - medium crate: - shuttle-auth + - shuttle-backends - shuttle-provisioner - shuttle-logger - test-workspace-member-with-integration-docker: diff --git a/Cargo.lock b/Cargo.lock index c0d82f9c3..c8b9c1c7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1006,7 +1006,6 @@ dependencies = [ "tempfile", "tokio", "tokio-tungstenite", - "tokiotest-httpserver", "toml", "toml_edit 0.20.7", "tonic 0.10.2", @@ -1377,8 +1376,18 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.13.4", + "darling_macro 0.13.4", +] + +[[package]] +name = "darling" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +dependencies = [ + "darling_core 0.20.8", + "darling_macro 0.20.8", ] [[package]] @@ -1395,17 +1404,42 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.52", +] + [[package]] name = "darling_macro" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" dependencies = [ - "darling_core", + "darling_core 0.13.4", "quote", "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +dependencies = [ + "darling_core 0.20.8", + "quote", + "syn 2.0.52", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -3519,6 +3553,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4037,6 +4081,34 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "permit-client-rs" +version = "2.0.0" +source = "git+https://github.com/shuttle-hq/permit-client-rs?rev=27c7759#27c775918aa6f7522e0845d8775a8b63d4124f6b" +dependencies = [ + "reqwest", + "serde", + "serde_derive", + "serde_json", + "serde_with 2.3.3", + "url", + "uuid", +] + +[[package]] +name = "permit-pdp-client-rs" +version = "0.2.0" +source = "git+https://github.com/shuttle-hq/permit-pdp-client-rs?rev=37c7296#37c72968adf360aa4e2386c3f0c918b823fb919f" +dependencies = [ + "reqwest", + "serde", + "serde_derive", + "serde_json", + "serde_with 2.3.3", + "url", + "uuid", +] + [[package]] name = "pin-project" version = "1.1.4" @@ -4285,12 +4357,6 @@ dependencies = [ "prost 0.12.3", ] -[[package]] -name = "queues" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1475abae4f8ad4998590fe3acfe20104f0a5d48fc420c817cd2c09c3f56151f0" - [[package]] name = "quick-error" version = "1.2.3" @@ -4494,6 +4560,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "once_cell", "percent-encoding", "pin-project-lite", @@ -4956,7 +5023,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" dependencies = [ "serde", - "serde_with_macros", + "serde_with_macros 1.5.2", +] + +[[package]] +name = "serde_with" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ff71d2c147a7b57362cead5e22f772cd52f6ab31cfcd9edcd7f6aeb2a0afbe" +dependencies = [ + "base64 0.13.1", + "chrono", + "hex", + "indexmap 1.9.3", + "serde", + "serde_json", + "serde_with_macros 2.3.3", + "time", ] [[package]] @@ -4982,12 +5065,24 @@ version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" dependencies = [ - "darling", + "darling 0.13.4", "proc-macro2", "quote", "syn 1.0.109", ] +[[package]] +name = "serde_with_macros" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881b6f881b17d13214e5d494c939ebab463d01264ce1811e9d4ac3a882e7695f" +dependencies = [ + "darling 0.20.8", + "proc-macro2", + "quote", + "syn 2.0.52", +] + [[package]] name = "serial_test" version = "3.0.0" @@ -5131,6 +5226,7 @@ dependencies = [ "axum", "base64 0.21.7", "bytes", + "ctor", "headers", "http 0.2.12", "http-body 0.4.6", @@ -5141,6 +5237,8 @@ dependencies = [ "opentelemetry-http 0.10.0", "opentelemetry-otlp", "opentelemetry_sdk", + "permit-client-rs", + "permit-pdp-client-rs", "pin-project", "portpicker", "reqwest", @@ -5150,20 +5248,22 @@ dependencies = [ "serde_json", "serial_test", "shuttle-common", + "shuttle-common-tests", "shuttle-proto", "sqlx", "strum 0.26.1", - "test-context 0.3.0", + "test-context", "thiserror", "tokio", "tonic 0.10.2", "tower", - "tower-http 0.4.4", + "tower-http", "tracing", "tracing-fluent-assertions", "tracing-opentelemetry", "tracing-subscriber", "ttl_cache", + "uuid", "wiremock", ] @@ -5335,11 +5435,11 @@ dependencies = [ "strum 0.26.1", "tar", "tempfile", - "test-context 0.3.0", + "test-context", "tokio", "tonic 0.10.2", "tower", - "tower-http 0.4.4", + "tower-http", "tower-sanitize-path", "tracing", "tracing-opentelemetry", @@ -6045,17 +6145,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" -[[package]] -name = "test-context" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7b6965c21232186af0092233c18030fe607cfc3960dbabb209325272458eeea" -dependencies = [ - "async-trait", - "futures", - "test-context-macros 0.1.6", -] - [[package]] name = "test-context" version = "0.3.0" @@ -6063,17 +6152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6676ab8513edfd2601a108621103fdb45cac9098305ca25ec93f7023b06b05d9" dependencies = [ "futures", - "test-context-macros 0.3.0", -] - -[[package]] -name = "test-context-macros" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d506c7664333e246f564949bee4ed39062aa0f11918e6f5a95f553cdad65c274" -dependencies = [ - "quote", - "syn 2.0.52", + "test-context-macros", ] [[package]] @@ -6226,19 +6305,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-test" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89b3cbabd3ae862100094ae433e1def582cf86451b4e9bf83aa7ac1d8a7d719" -dependencies = [ - "async-stream", - "bytes", - "futures-core", - "tokio", - "tokio-stream", -] - [[package]] name = "tokio-tungstenite" version = "0.20.1" @@ -6269,23 +6335,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tokiotest-httpserver" -version = "0.2.1" -source = "git+https://github.com/shuttle-hq/tokiotest-httpserver?branch=feat/body#ca413a227397f0d0441b4454581ddd803acabdd7" -dependencies = [ - "async-trait", - "futures", - "hyper 0.14.28", - "lazy_static", - "queues", - "serde_json", - "test-context 0.1.6", - "tokio", - "tokio-test", - "tower-http 0.2.5", -] - [[package]] name = "toml" version = "0.8.10" @@ -6406,24 +6455,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower-http" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aba3f3efabf7fb41fae8534fc20a817013dd1c12cb45441efb6c82e6556b4cd8" -dependencies = [ - "bitflags 1.3.2", - "bytes", - "futures-core", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "http-range-header", - "pin-project-lite", - "tower-layer", - "tower-service", -] - [[package]] name = "tower-http" version = "0.4.4" @@ -6747,6 +6778,15 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" diff --git a/auth/src/args.rs b/auth/src/args.rs index 0a1740fd8..4debcea6d 100644 --- a/auth/src/args.rs +++ b/auth/src/args.rs @@ -20,6 +20,9 @@ pub enum Commands { InitAdmin(InitArgs), InitDeployer(InitArgs), Sync(SyncArgs), + /// Copy and overwrite a permit env's policies to another env. + /// Requires a project level API key. + CopyPermitEnv(CopyPermitEnvArgs), } #[derive(clap::Args, Debug, Clone)] @@ -57,6 +60,14 @@ pub struct SyncArgs { pub permit: PermitArgs, } +#[derive(clap::Args, Debug, Clone)] +pub struct CopyPermitEnvArgs { + /// environment to copy to + pub target: String, + #[command(flatten)] + pub permit: PermitArgs, +} + #[derive(clap::Args, Debug, Clone)] pub struct PermitArgs { /// Address to reach the permit.io API at diff --git a/auth/src/lib.rs b/auth/src/lib.rs index 3586fda5c..0011f5da3 100644 --- a/auth/src/lib.rs +++ b/auth/src/lib.rs @@ -4,10 +4,8 @@ mod error; mod secrets; mod user; -use std::io; - -use args::{StartArgs, SyncArgs}; -use http::StatusCode; +use anyhow::Result; +use args::{CopyPermitEnvArgs, StartArgs, SyncArgs}; use shuttle_backends::client::{permit, PermissionsDal}; use shuttle_common::{claims::AccountTier, ApiKey}; use sqlx::{migrate::Migrator, query, PgPool}; @@ -20,16 +18,16 @@ pub use args::{Args, Commands, InitArgs}; pub static MIGRATIONS: Migrator = sqlx::migrate!("./migrations"); -pub async fn start(pool: PgPool, args: StartArgs) -> io::Result<()> { +pub async fn start(pool: PgPool, args: StartArgs) { let router = api::ApiBuilder::new() .with_pg_pool(pool) .with_stripe_client(stripe::Client::new(args.stripe_secret_key)) .with_permissions_client(permit::Client::new( - args.permit.permit_api_uri, - args.permit.permit_pdp_uri, + args.permit.permit_api_uri.to_string(), + args.permit.permit_pdp_uri.to_string(), "default".to_string(), args.permit.permit_env, - &args.permit.permit_api_key, + args.permit.permit_api_key, )) .with_jwt_signing_private_key(args.jwt_signing_private_key) .into_router(); @@ -37,29 +35,29 @@ pub async fn start(pool: PgPool, args: StartArgs) -> io::Result<()> { info!(address=%args.address, "Binding to and listening at address"); serve(router, args.address).await; - - Ok(()) } -pub async fn sync(pool: PgPool, args: SyncArgs) -> io::Result<()> { +pub async fn sync(pool: PgPool, args: SyncArgs) -> Result<()> { let users: Vec = sqlx::query_as("SELECT * FROM users") .fetch_all(&pool) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .await?; let permit_client = permit::Client::new( - args.permit.permit_api_uri, - args.permit.permit_pdp_uri, + args.permit.permit_api_uri.to_string(), + args.permit.permit_pdp_uri.to_string(), "default".to_string(), args.permit.permit_env, - &args.permit.permit_api_key, + args.permit.permit_api_key, ); for user in users { match permit_client.get_user(&user.id).await { Ok(p_user) => { // Update tier if out of sync - if !p_user.roles.contains(&user.account_tier) { + if !p_user + .roles + .is_some_and(|rs| rs.iter().any(|r| r.role == user.account_tier.to_string())) + { match user.account_tier { AccountTier::Basic | AccountTier::PendingPaymentPro @@ -67,44 +65,45 @@ pub async fn sync(pool: PgPool, args: SyncArgs) -> io::Result<()> { | AccountTier::Team | AccountTier::Admin | AccountTier::Deployer => { - permit_client - .make_free(&user.id) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + permit_client.make_basic(&user.id).await?; } AccountTier::Pro => { - permit_client - .make_pro(&user.id) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + permit_client.make_pro(&user.id).await?; } } } } - Err(shuttle_backends::client::Error::RequestError(StatusCode::NOT_FOUND)) => { + Err(_) => { + // FIXME: Make the error type better so that this is only done on 404s + println!("creating user: {}", user.id); // Add users that are not in permit - permit_client - .new_user(&user.id) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + permit_client.new_user(&user.id).await?; if user.account_tier == AccountTier::Pro { - permit_client - .make_pro(&user.id) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + permit_client.make_pro(&user.id).await?; } } - Err(e) => return Err(io::Error::new(io::ErrorKind::Other, e)), } } Ok(()) } -pub async fn init(pool: PgPool, args: InitArgs, tier: AccountTier) -> io::Result<()> { +pub async fn copy_environment(args: CopyPermitEnvArgs) -> Result<()> { + let client = permit::Client::new( + args.permit.permit_api_uri.to_string(), + args.permit.permit_pdp_uri.to_string(), + "default".to_string(), + args.permit.permit_env, + args.permit.permit_api_key, + ); + + client.copy_environment(&args.target).await +} + +pub async fn init(pool: PgPool, args: InitArgs, tier: AccountTier) -> Result<()> { let key = match args.key { Some(ref key) => ApiKey::parse(key).unwrap(), None => ApiKey::generate(), @@ -116,8 +115,7 @@ pub async fn init(pool: PgPool, args: InitArgs, tier: AccountTier) -> io::Result .bind(tier.to_string()) .bind(&args.user_id) .execute(&pool) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + .await?; println!( "`{}` created as {} with key: {}", @@ -129,17 +127,10 @@ pub async fn init(pool: PgPool, args: InitArgs, tier: AccountTier) -> io::Result } /// Initialize the connection pool to a Postgres database at the given URI. -pub async fn pgpool_init(db_uri: &str) -> io::Result { - let opts = db_uri - .parse() - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - let pool = PgPool::connect_with(opts) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; - MIGRATIONS - .run(&pool) - .await - .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; +pub async fn pgpool_init(db_uri: &str) -> Result { + let opts = db_uri.parse()?; + let pool = PgPool::connect_with(opts).await?; + MIGRATIONS.run(&pool).await?; Ok(pool) } diff --git a/auth/src/main.rs b/auth/src/main.rs index 174d28d54..9e3f51247 100644 --- a/auth/src/main.rs +++ b/auth/src/main.rs @@ -1,22 +1,24 @@ -use std::io; - use clap::Parser; use shuttle_backends::trace::setup_tracing; use shuttle_common::{claims::AccountTier, log::Backend}; use sqlx::migrate::Migrator; use tracing::trace; -use shuttle_auth::{init, pgpool_init, start, sync, Args, Commands}; +use shuttle_auth::{copy_environment, init, pgpool_init, start, sync, Args, Commands}; pub static MIGRATIONS: Migrator = sqlx::migrate!("./migrations"); #[tokio::main] -async fn main() -> io::Result<()> { - let args = Args::parse(); +async fn main() { + setup_tracing(tracing_subscriber::registry(), Backend::Auth); + let args = Args::parse(); trace!(args = ?args, "parsed args"); - setup_tracing(tracing_subscriber::registry(), Backend::Auth); + if let Commands::CopyPermitEnv(args) = args.command { + copy_environment(args).await.map_err(|e| dbg!(e)).unwrap(); + return; + } let pool = pgpool_init(args.db_connection_uri.as_str()) .await @@ -24,8 +26,9 @@ async fn main() -> io::Result<()> { match args.command { Commands::Start(args) => start(pool, args).await, - Commands::InitAdmin(args) => init(pool, args, AccountTier::Admin).await, - Commands::InitDeployer(args) => init(pool, args, AccountTier::Deployer).await, - Commands::Sync(args) => sync(pool, args).await, + Commands::InitAdmin(args) => init(pool, args, AccountTier::Admin).await.unwrap(), + Commands::InitDeployer(args) => init(pool, args, AccountTier::Deployer).await.unwrap(), + Commands::Sync(args) => sync(pool, args).await.unwrap(), + _ => unreachable!(), } } diff --git a/auth/src/user.rs b/auth/src/user.rs index f377d25e6..d08814a52 100644 --- a/auth/src/user.rs +++ b/auth/src/user.rs @@ -119,7 +119,7 @@ where if tier == AccountTier::Pro { self.permissions_client.make_pro(user_id).await?; } else { - self.permissions_client.make_free(user_id).await?; + self.permissions_client.make_basic(user_id).await?; } if rows_affected > 0 { @@ -254,7 +254,7 @@ where if subscription.r#type == models::user::SubscriptionType::Pro { self.update_tier(user_id, AccountTier::CancelledPro).await?; - self.permissions_client.make_free(user_id).await?; + self.permissions_client.make_basic(user_id).await?; } else { query( r#"DELETE FROM subscriptions diff --git a/auth/tests/api/users.rs b/auth/tests/api/users.rs index 37f0d3f1b..54b8e5f08 100644 --- a/auth/tests/api/users.rs +++ b/auth/tests/api/users.rs @@ -12,7 +12,7 @@ mod needs_docker { use hyper::http::{header::AUTHORIZATION, Request, StatusCode}; use pretty_assertions::assert_eq; use serde_json::{self, Value}; - use shuttle_common::{claims::AccountTier, models::user}; + use shuttle_common::models::user; #[tokio::test] async fn post_user() { @@ -48,24 +48,13 @@ mod needs_docker { let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); let user: user::Response = serde_json::from_slice(&body).unwrap(); + let user_id1 = user.id.clone(); assert_eq!(user.name, "test-user"); assert_eq!(user.account_tier, "basic"); assert!(user.id.starts_with("user_")); assert!(user.key.is_ascii()); - assert_eq!( - app.permissions - .users - .read() - .unwrap() - .get(&user.id) - .unwrap() - .roles, - vec![AccountTier::Basic], - "should default to basic tier" - ); - // POST user with valid bearer token and pro tier. let response = app.post_user("pro-user", "pro").await; @@ -73,6 +62,7 @@ mod needs_docker { let body = hyper::body::to_bytes(response.into_body()).await.unwrap(); let user: user::Response = serde_json::from_slice(&body).unwrap(); + let user_id2 = user.id.clone(); assert_eq!(user.name, "pro-user"); assert_eq!(user.account_tier, "pro"); @@ -80,14 +70,12 @@ mod needs_docker { assert!(user.key.is_ascii()); assert_eq!( - app.permissions - .users - .read() - .unwrap() - .get(&user.id) - .unwrap() - .roles, - vec![AccountTier::Pro] + *app.permissions.calls.lock().await, + [ + format!("new_user {user_id1}"), + format!("new_user {user_id2}"), + format!("make_pro {user_id2}"), + ] ); } @@ -176,9 +164,7 @@ mod needs_docker { let pro_user: user::Response = serde_json::from_slice(&body).unwrap(); assert_eq!(user.name, pro_user.name); - assert_eq!(user.key, pro_user.key); - assert_eq!(pro_user.account_tier, "pro"); let mocked_subscription_obj: Value = @@ -189,15 +175,8 @@ mod needs_docker { ); assert_eq!( - app.permissions - .users - .read() - .unwrap() - .get(&user.id) - .unwrap() - .roles, - vec![AccountTier::Pro], - "should have updated the permissions too" + *app.permissions.calls.lock().await, + [format!("new_user {user_id}"), format!("make_pro {user_id}")] ); } @@ -239,16 +218,14 @@ mod needs_docker { let actual_user: user::Response = serde_json::from_slice(&body).unwrap(); assert_eq!(actual_user.account_tier, "pendingpaymentpro"); + assert_eq!( - app.permissions - .users - .read() - .unwrap() - .get(&actual_user.id) - .unwrap() - .roles, - vec![AccountTier::Basic], - "should have updated the permissions too" + *app.permissions.calls.lock().await, + [ + format!("new_user {user_id}"), + format!("make_pro {user_id}"), + format!("make_basic {user_id}"), + ] ); } @@ -291,18 +268,6 @@ mod needs_docker { ); assert_eq!(response.subscriptions[0].quantity, 1); - assert_eq!( - app.permissions - .users - .read() - .unwrap() - .get(&response.id) - .unwrap() - .roles, - vec![AccountTier::Basic], - "RDS subscription should not change the account tier" - ); - // Make sure JWT has the quota let claim = app.get_claim(basic_user_key).await; assert_eq!(claim.limits.rds_quota(), 1); @@ -327,6 +292,11 @@ mod needs_docker { // Make sure JWT is reset correctly let claim = app.get_claim(basic_user_key).await; assert_eq!(claim.limits.rds_quota(), 0); + + assert_eq!( + *app.permissions.calls.lock().await, + [format!("new_user {user_id}")] + ); } #[tokio::test] @@ -398,18 +368,6 @@ mod needs_docker { let user: user::Response = serde_json::from_slice(&body).unwrap(); assert_eq!(user.account_tier, "cancelledpro"); - assert_eq!( - app.permissions - .users - .read() - .unwrap() - .get(&user.id) - .unwrap() - .roles, - vec![AccountTier::Basic], - "permissions should be updated to basic" - ); - // When called again at some later time, the subscription returned from stripe should be // cancelled. let response = app @@ -425,16 +383,16 @@ mod needs_docker { let user: user::Response = serde_json::from_slice(&body).unwrap(); assert_eq!(user.account_tier, "basic"); + assert_eq!( - app.permissions - .users - .read() - .unwrap() - .get(&user.id) - .unwrap() - .roles, - vec![AccountTier::Basic], - "permissions should still be basic" + *app.permissions.calls.lock().await, + [ + format!("new_user {user_id}"), + format!("make_pro {user_id}"), + format!("make_basic {user_id}"), + format!("make_basic {user_id}"), + format!("make_basic {user_id}"), + ] ); } } diff --git a/backends/Cargo.toml b/backends/Cargo.toml index d84e44776..389406a33 100644 --- a/backends/Cargo.toml +++ b/backends/Cargo.toml @@ -24,6 +24,8 @@ 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-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 @@ -49,8 +51,11 @@ test-utils = ["portpicker", "wiremock"] [dev-dependencies] base64 = { workspace = true } +ctor = { workspace = true } jsonwebtoken = { workspace = true } ring = { workspace = true } serial_test = "3.0.0" +shuttle-common-tests = { workspace = true } test-context = { workspace = true } tracing-fluent-assertions = "0.3.0" +uuid = { workspace = true } diff --git a/backends/src/client/permit.rs b/backends/src/client/permit.rs index 3f657e5e4..a3dde9e78 100644 --- a/backends/src/client/permit.rs +++ b/backends/src/client/permit.rs @@ -1,640 +1,545 @@ -use std::collections::HashMap; - +use anyhow::Error; use async_trait::async_trait; -use http::{Method, Uri}; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::{json, Value}; +use permit_client_rs::{ + apis::{ + resource_instances_api::{create_resource_instance, delete_resource_instance}, + role_assignments_api::{assign_role, unassign_role}, + users_api::{create_user, delete_user, get_user}, + }, + models::{ + ResourceInstanceCreate, RoleAssignmentCreate, RoleAssignmentRemove, UserCreate, UserRead, + }, +}; +use permit_pdp_client_rs::{ + apis::{ + authorization_api_api::{ + get_user_permissions_user_permissions_post, is_allowed_allowed_post, + }, + data_updater_api::trigger_policy_data_update_data_updater_trigger_post, + policy_updater_api::trigger_policy_update_policy_updater_trigger_post, + }, + models::{AuthorizationQuery, Resource, User, UserPermissionsQuery, UserPermissionsResult}, +}; use shuttle_common::claims::AccountTier; -use super::{Error, ServicesApiClient}; - #[async_trait] pub trait PermissionsDal { - /// Get a user with the given ID - async fn get_user(&self, user_id: &str) -> Result; + // User management + /// Get a user with the given ID + 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>; - /// 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>; + /// Set a user to be a Basic user + async fn make_basic(&self, user_id: &str) -> Result<(), Error>; - /// Set a user to be a Free user - async fn make_free(&self, user_id: &str) -> Result<(), Error>; -} + // Project management -/// Simple user -#[derive(Clone, Deserialize, Debug)] -pub struct User { - pub id: String, - pub key: String, - #[serde(deserialize_with = "deserialize_role")] - pub roles: Vec, -} - -#[derive(Deserialize)] -struct RoleObject { - role: AccountTier, -} + /// 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>; + /// Deletes a Project resource + async fn delete_project(&self, project_id: &str) -> Result<(), Error>; -// Used to convert a Permit role into our internal `AccountTier` enum -fn deserialize_role<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - let roles = Vec::::deserialize(deserializer)?; + // Organization management - let mut role_vec: Vec = roles.into_iter().map(|r| r.role).collect(); + ////// TODO - role_vec.sort(); + // Permissions queries - Ok(role_vec) + /// Get list of all projects user has permissions for + async fn get_user_projects(&self, user_id: &str) -> Result, Error>; + /// Check if user can perform action on this project + async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result; } +/// Wrapper for the Permit.io API and PDP (Policy decision point) API #[derive(Clone)] pub struct Client { - /// The Permit.io API - api: ServicesApiClient, - /// The local Permit PDP (Policy decision point) API - pdp: ServicesApiClient, - /// The base URL path for 'facts' endpoints. Helps with building full URLs. - facts: String, + pub api: permit_client_rs::apis::configuration::Configuration, + pub pdp: permit_pdp_client_rs::apis::configuration::Configuration, + pub proj_id: String, + pub env_id: String, } impl Client { - pub fn new(api_uri: Uri, pdp_uri: Uri, proj_id: String, env_id: String, api_key: &str) -> Self { + pub fn new( + api_uri: String, + pdp_uri: String, + proj_id: String, + env_id: String, + api_key: String, + ) -> Self { Self { - api: ServicesApiClient::new_with_bearer(api_uri, api_key), - pdp: ServicesApiClient::new(pdp_uri), - facts: format!("v2/facts/{}/{}", proj_id, env_id), + api: permit_client_rs::apis::configuration::Configuration { + base_path: api_uri + .strip_suffix('/') + .map(ToOwned::to_owned) + .unwrap_or(api_uri), + user_agent: None, + bearer_access_token: Some(api_key.clone()), + ..Default::default() + }, + pdp: permit_pdp_client_rs::apis::configuration::Configuration { + base_path: pdp_uri + .strip_suffix('/') + .map(ToOwned::to_owned) + .unwrap_or(pdp_uri), + user_agent: None, + bearer_access_token: Some(api_key), + ..Default::default() + }, + proj_id, + env_id, } } +} - /// Creates a Project resource and assigns the user as admin for that project - pub async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> { - self.api - .post( - &format!("{}/resource_instances", self.facts), - json!({ - "key": project_id, - "tenant": "default", - "resource": "Project", - }), - None, - ) - .await?; - - self.api - .post( - &format!("{}/role_assignments", self.facts), - json!({ - "role": "admin", - "resource_instance": format!("Project:{project_id}"), - "tenant": "default", - "user": user_id, - }), - None, - ) - .await +#[async_trait] +impl PermissionsDal for Client { + async fn get_user(&self, user_id: &str) -> Result { + Ok(get_user(&self.api, &self.proj_id, &self.env_id, user_id).await?) } - /// Unassigns the admin role for a user on a project - pub async fn delete_user_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> { - self.api - .delete( - &format!("{}/role_assignments", self.facts), - json!({ - "role": "admin", - "resource_instance": format!("Project:{project_id}"), - "tenant": "default", - "user": user_id, - }), - None, - ) - .await + async fn delete_user(&self, user_id: &str) -> Result<(), Error> { + Ok(delete_user(&self.api, &self.proj_id, &self.env_id, user_id).await?) } - /// 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 - } + 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?; - pub async fn delete_organization(&self, organization_id: &str) -> Result<(), Error> { - self.api - .request( - Method::DELETE, - &format!("{}/resource_instances/{organization_id}", self.facts), - None::<()>, - None, - ) - .await + self.get_user(&user.id.to_string()).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 - } + async fn make_pro(&self, user_id: &str) -> Result<(), Error> { + let user = self.get_user(user_id).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?; + if user.roles.is_some_and(|roles| { + roles + .iter() + .any(|r| r.role == AccountTier::Basic.to_string()) + }) { + self.unassign_role(user_id, &AccountTier::Basic).await?; + } - Ok(res[0].as_object().unwrap()["role"].as_str().unwrap() == "admin") + self.assign_role(user_id, &AccountTier::Pro).await } - 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 - } + async fn make_basic(&self, user_id: &str) -> Result<(), Error> { + let user = self.get_user(user_id).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 - } + if user + .roles + .is_some_and(|roles| roles.iter().any(|r| r.role == AccountTier::Pro.to_string())) + { + self.unassign_role(user_id, &AccountTier::Pro).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 + self.assign_role(user_id, &AccountTier::Basic).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 - } + async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> { + create_resource_instance( + &self.api, + &self.proj_id, + &self.env_id, + ResourceInstanceCreate { + key: project_id.to_owned(), + tenant: "default".to_owned(), + resource: "Project".to_owned(), + attributes: None, + }, + ) + .await?; + + self.assign_resource_role(user_id, format!("Project:{project_id}"), "admin") + .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 + Ok(()) } - 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 delete_project(&self, project_id: &str) -> Result<(), Error> { + Ok(delete_resource_instance( + &self.api, + &self.proj_id, + &self.env_id, + format!("Project:{project_id}").as_str(), + ) + .await?) } - pub async fn get_user_projects(&self, user_id: &str) -> Result, Error> { - let perms: HashMap = self - .pdp - .post( - "/user-permissions", - json!({ - "user": {"key": user_id}, - "resource_types": ["Project"], + async fn get_user_projects(&self, user_id: &str) -> Result, Error> { + let perms = get_user_permissions_user_permissions_post( + &self.pdp, + UserPermissionsQuery { + user: Box::new(User { + key: user_id.to_owned(), + ..Default::default() }), - None, - ) - .await?; + resource_types: Some(vec!["Project".to_owned()]), + tenants: Some(vec!["default".to_owned()]), + ..Default::default() + }, + None, + None, + ) + .await?; Ok(perms.into_values().collect()) } - pub async fn allowed( - &self, - user_id: &str, - project_id: &str, - action: &str, - ) -> Result { - let res: Value = self - .pdp - .post( - "/allowed", - json!({ - "user": {"key": user_id}, - "action": action, - "resource": {"type": "Project", "key": project_id, "tenant": "default"}, + 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, + AuthorizationQuery { + user: Box::new(User { + key: user_id.to_owned(), + ..Default::default() }), - None, - ) - .await?; - - Ok(res["allow"].as_bool().unwrap()) + action: action.to_owned(), + resource: Box::new(Resource { + r#type: "Project".to_string(), + key: Some(project_id.to_owned()), + tenant: Some("default".to_owned()), + ..Default::default() + }), + ..Default::default() + }, + None, + None, + ) + .await?; + + Ok(res.allow.unwrap_or_default()) } +} - async fn create_user(&self, user_id: &str) -> Result { - self.api - .post( - &format!("{}/users", self.facts), - json!({"key": user_id}), - None, - ) - .await +// 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 { + Ok(create_user( + &self.api, + &self.proj_id, + &self.env_id, + UserCreate { + key: user_id.to_owned(), + ..Default::default() + }, + ) + .await?) } async fn assign_role(&self, user_id: &str, role: &AccountTier) -> Result<(), Error> { - self.api - .request_raw( - Method::POST, - &format!("{}/users/{user_id}/roles", self.facts), - Some(json!({ - "role": role, - "tenant": "default", - })), - None, - ) - .await?; + assign_role( + &self.api, + &self.proj_id, + &self.env_id, + RoleAssignmentCreate { + role: role.to_string(), + tenant: Some("default".to_owned()), + resource_instance: None, + user: user_id.to_owned(), + }, + ) + .await?; Ok(()) } async fn unassign_role(&self, user_id: &str, role: &AccountTier) -> Result<(), Error> { - self.api - .request_raw( - Method::DELETE, - &format!("{}/users/{user_id}/roles", self.facts), - Some(json!({ - "role": role, - "tenant": "default", - })), - None, - ) - .await?; + unassign_role( + &self.api, + &self.proj_id, + &self.env_id, + RoleAssignmentRemove { + role: role.to_string(), + tenant: Some("default".to_owned()), + resource_instance: None, + user: user_id.to_owned(), + }, + ) + .await?; Ok(()) } -} - -#[async_trait] -impl PermissionsDal for Client { - async fn get_user(&self, user_id: &str) -> Result { - self.api - .get(&format!("{}/users/{user_id}", self.facts), None) - .await - } - async fn delete_user(&self, user_id: &str) -> Result<(), Error> { - self.api - .request_raw( - Method::DELETE, - &format!("{}/users/{user_id}", self.facts), - None::<()>, - None, - ) - .await?; + async fn assign_resource_role( + &self, + user_id: &str, + resource_instance: String, + role: &str, + ) -> Result<(), Error> { + assign_role( + &self.api, + &self.proj_id, + &self.env_id, + RoleAssignmentCreate { + role: role.to_owned(), + tenant: Some("default".to_owned()), + resource_instance: Some(resource_instance), + user: user_id.to_owned(), + }, + ) + .await?; Ok(()) } - async fn new_user(&self, user_id: &str) -> Result { - let user = self.create_user(user_id).await?; - self.make_free(&user.id).await?; - - self.get_user(&user.id).await - } - - async fn make_pro(&self, user_id: &str) -> Result<(), Error> { - let user = self.get_user(user_id).await?; - - if user.roles.contains(&AccountTier::Basic) { - self.unassign_role(user_id, &AccountTier::Basic).await?; - } + async fn _unassign_resource_role( + &self, + user_id: &str, + resource_instance: String, + role: &str, + ) -> Result<(), Error> { + unassign_role( + &self.api, + &self.proj_id, + &self.env_id, + RoleAssignmentRemove { + role: role.to_owned(), + tenant: Some("default".to_owned()), + resource_instance: Some(resource_instance), + user: user_id.to_owned(), + }, + ) + .await?; - self.assign_role(user_id, &AccountTier::Pro).await + Ok(()) } - async fn make_free(&self, user_id: &str) -> Result<(), Error> { - let user = self.get_user(user_id).await?; + pub async fn sync_pdp(&self) -> Result<(), Error> { + trigger_policy_update_policy_updater_trigger_post(&self.pdp).await?; + trigger_policy_data_update_data_updater_trigger_post(&self.pdp).await?; - if user.roles.contains(&AccountTier::Pro) { - self.unassign_role(user_id, &AccountTier::Pro).await?; - } - - self.assign_role(user_id, &AccountTier::Basic).await + Ok(()) } } -/// Struct to hold the following relationship tuple from permit -/// -/// ```json -/// { -/// "subject": "Organization:London", -/// "relation": "parent", -/// "object": "Project:01HRAER7SMNPYZR3RYPAGHMFYW", -/// "id": "dfb57d795ba1432192a5b0ffd0293cae", -/// "tenant": "default", -/// "subject_id": "6eb3094331694b09ac1596fdb7834be5", -/// "relation_id": "cc1bf6e3e51e4b588c36a04552427461", -/// "object_id": "0af595f5ce834c7cad1cca513a1a6fd2", -/// "tenant_id": "4da8b268e96644609978dd62041b5fc6", -/// "organization_id": "5f504714eee841aaaef0d9546d2fd998", -/// "project_id": "b3492c78ccf44f7fb72615bdbfa58027", -/// "environment_id": "b3d12e0fd440433c8ba480bde8cb6cd2", -/// "created_at": "2024-03-07T15:27:59+00:00", -/// "updated_at": "2024-03-07T15:27:59+00:00", -/// "subject_details": { -/// "key": "London", -/// "tenant": "default", -/// "resource": "Organization", -/// "attributes": {} -/// }, -/// "relation_details": { -/// "key": "parent", -/// "name": "parent", -/// "description": "Relation expresses possible 'parent' relation between subject of type 'Organization' to object of type 'Project'" -/// }, -/// "object_details": { -/// "key": "01HRAER7SMNPYZR3RYPAGHMFYW", -/// "tenant": "default", -/// "resource": "Project", -/// "attributes": {} -/// }, -/// "tenant_details": { -/// "key": "default", -/// "name": "Default Tenant", -/// "description": null, -/// "attributes": null -/// } -/// } -/// ``` -#[derive(Debug, Serialize, Deserialize)] -pub struct OrganizationResource { - pub subject: String, - pub relation: String, - pub object: String, - pub id: String, - - /// The project which this organization is the parent of - pub object_details: ObjectDetails, - - #[serde(flatten)] - pub extra: HashMap, -} - -/// Struct to hold the following -/// ```json -/// { -/// "key": "01HRAER7SMNPYZR3RYPAGHMFYW", -/// "tenant": "default", -/// "resource": "Project", -/// "attributes": {} -/// } -/// ``` -#[derive(Debug, Serialize, Deserialize)] -pub struct ObjectDetails { - pub key: String, - #[serde(default)] - pub name: String, - pub tenant: String, - pub resource: String, - pub attributes: Value, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ProjectPermissions { - pub resource: SimpleResource, - pub permissions: Vec, - pub roles: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct SimpleResource { - pub key: String, - pub r#type: String, - pub attributes: Value, -} - -#[cfg(test)] -mod tests { - use std::env; - - use http::StatusCode; - use serde_json::Value; - use serial_test::serial; - use test_context::{test_context, AsyncTestContext}; - - use crate::client::Error; +// #[cfg(feature = "admin")] +mod admin { + use permit_client_rs::{ + apis::environments_api::copy_environment, + models::{ + environment_copy::ConflictStrategy, EnvironmentCopy, EnvironmentCopyScope, + EnvironmentCopyScopeFilters, EnvironmentCopyTarget, + }, + }; use super::*; impl Client { - async fn clear_users(&self) { - let users: Value = self - .api - .get(&format!("{}/users", self.facts), None) - .await - .unwrap(); - - for user in users["data"].as_array().unwrap() { - let user_id = user["id"].as_str().unwrap(); - self.delete_user(user_id).await.unwrap(); - } - } - } - - impl AsyncTestContext for Client { - async fn setup() -> Self { - let api_key = env::var("PERMIT_API_KEY").expect("PERMIT_API_KEY to be set. You can copy the testing API key from the Testing environment on Permit.io."); - let client = Client::new( - "https://api.eu-central-1.permit.io".parse().unwrap(), - "http://localhost:7000".parse().unwrap(), - "default".to_string(), - "testing".to_string(), - &api_key, - ); - - client.clear_users().await; - - client - } + /// Copy and overwrite the policies of one env to another existing one + pub async fn copy_environment(&self, target_env: &str) -> Result<(), Error> { + copy_environment( + &self.api, + &self.proj_id, + &self.env_id, + EnvironmentCopy { + target_env: Box::new(EnvironmentCopyTarget { + existing: Some(target_env.to_owned()), + new: None, + }), + conflict_strategy: Some(ConflictStrategy::Overwrite), + scope: Some(Box::new(EnvironmentCopyScope { + resources: Some(Box::new(EnvironmentCopyScopeFilters { + include: Some(vec!["*".to_owned()]), + exclude: None, + })), + roles: Some(Box::new(EnvironmentCopyScopeFilters { + include: Some(vec!["*".to_owned()]), + exclude: None, + })), + user_sets: Some(Box::new(EnvironmentCopyScopeFilters { + include: Some(vec!["*".to_owned()]), + exclude: None, + })), + resource_sets: Some(Box::new(EnvironmentCopyScopeFilters { + include: Some(vec!["*".to_owned()]), + exclude: None, + })), + })), + }, + ) + .await?; - async fn teardown(self) { - self.clear_users().await; + Ok(()) } } - - #[test_context(Client)] - #[tokio::test] - #[serial] - async fn test_user_flow(client: &mut Client) { - let user = client.create_user("test_user").await.unwrap(); - let user_actual = client.get_user("test_user").await.unwrap(); - - assert_eq!(user.id, user_actual.id); - - // Can also get user by permit id - client.get_user(&user.id).await.unwrap(); - - // Now delete the user - client.delete_user("test_user").await.unwrap(); - let res = client.get_user("test_user").await; - - assert!(matches!( - res, - Err(Error::RequestError(StatusCode::NOT_FOUND)) - )); - } - - #[test_context(Client)] - #[tokio::test] - #[serial] - async fn test_tiers_flow(client: &mut Client) { - let user = client.create_user("tier_user").await.unwrap(); - - assert!(user.roles.is_empty()); - - // Make user a pro - client - .assign_role("tier_user", &AccountTier::Pro) - .await - .unwrap(); - let user = client.get_user("tier_user").await.unwrap(); - - assert_eq!(user.roles, vec![AccountTier::Pro]); - - // Make user a free user - client - .assign_role("tier_user", &AccountTier::Basic) - .await - .unwrap(); - let user = client.get_user("tier_user").await.unwrap(); - - assert_eq!(user.roles, vec![AccountTier::Basic, AccountTier::Pro]); - - // Remove the pro role - client - .unassign_role("tier_user", &AccountTier::Pro) - .await - .unwrap(); - let user = client.get_user("tier_user").await.unwrap(); - - assert_eq!(user.roles, vec![AccountTier::Basic]); - } - - #[test_context(Client)] - #[tokio::test] - #[serial] - async fn test_user_complex_flow(client: &mut Client) { - let user = client.new_user("jane").await.unwrap(); - assert_eq!( - user.roles, - vec![AccountTier::Basic], - "making a new user should default to Free tier" - ); - - client.make_pro("jane").await.unwrap(); - client.make_pro("jane").await.unwrap(); - - let user = client.get_user("jane").await.unwrap(); - assert_eq!( - user.roles, - vec![AccountTier::Pro], - "changing to Pro should remove Free" - ); - } } diff --git a/backends/src/test_utils/gateway.rs b/backends/src/test_utils/gateway.rs index f2d96a5f7..540b14d67 100644 --- a/backends/src/test_utils/gateway.rs +++ b/backends/src/test_utils/gateway.rs @@ -1,18 +1,18 @@ -use std::{ - collections::HashMap, - sync::{Arc, RwLock}, -}; +use std::sync::Arc; +use anyhow::Error; use async_trait::async_trait; +use permit_client_rs::models::UserRead; +use permit_pdp_client_rs::models::UserPermissionsResult; use serde::Serialize; -use shuttle_common::claims::AccountTier; +use tokio::sync::Mutex; use wiremock::{ http, matchers::{method, path, path_regex}, Mock, MockServer, Request, ResponseTemplate, }; -use crate::client::{permit::User, Error, PermissionsDal}; +use crate::client::PermissionsDal; pub async fn get_mocked_gateway_server() -> MockServer { let mock_server = MockServer::start().await; @@ -93,43 +93,71 @@ struct Project<'a> { #[derive(Clone, Default)] pub struct PermissionsMock { - pub users: Arc>>, + pub calls: Arc>>, } #[async_trait] impl PermissionsDal for PermissionsMock { - async fn get_user(&self, _user_id: &str) -> Result { - unimplemented!() + 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> { - unimplemented!() + async fn delete_user(&self, user_id: &str) -> Result<(), Error> { + self.calls + .lock() + .await + .push(format!("delete_user {user_id}")); + Ok(()) } - async fn new_user(&self, user_id: &str) -> Result { - let user = User { - id: user_id.to_string(), - key: user_id.to_string(), - roles: vec![AccountTier::Basic], - }; - - self.users - .write() - .unwrap() - .insert(user_id.to_string(), user.clone()); - - Ok(user) + 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> { - self.users.write().unwrap().get_mut(user_id).unwrap().roles = vec![AccountTier::Pro]; + self.calls.lock().await.push(format!("make_pro {user_id}")); + Ok(()) + } + async fn make_basic(&self, user_id: &str) -> Result<(), Error> { + self.calls + .lock() + .await + .push(format!("make_basic {user_id}")); Ok(()) } - async fn make_free(&self, user_id: &str) -> Result<(), Error> { - self.users.write().unwrap().get_mut(user_id).unwrap().roles = vec![AccountTier::Basic]; + async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> { + self.calls + .lock() + .await + .push(format!("create_project {user_id} {project_id}")); + Ok(()) + } + async fn delete_project(&self, project_id: &str) -> Result<(), Error> { + self.calls + .lock() + .await + .push(format!("delete_project {project_id}")); Ok(()) } + + async fn get_user_projects(&self, user_id: &str) -> Result, Error> { + self.calls + .lock() + .await + .push(format!("get_user_projects {user_id}")); + Ok(vec![]) + } + + 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) + } } diff --git a/backends/tests/integration/main.rs b/backends/tests/integration/main.rs new file mode 100644 index 000000000..0b28b6d8a --- /dev/null +++ b/backends/tests/integration/main.rs @@ -0,0 +1 @@ +mod permit_tests; diff --git a/backends/tests/integration/permit_tests.rs b/backends/tests/integration/permit_tests.rs new file mode 100644 index 000000000..37625b968 --- /dev/null +++ b/backends/tests/integration/permit_tests.rs @@ -0,0 +1,195 @@ +mod needs_docker { + use std::sync::OnceLock; + + use permit_client_rs::apis::{ + resource_instances_api::{delete_resource_instance, list_resource_instances}, + users_api::list_users, + }; + use serial_test::serial; + use shuttle_backends::client::{permit::Client, PermissionsDal}; + use shuttle_common::claims::AccountTier; + use shuttle_common_tests::permit_pdp::DockerInstance; + use test_context::{test_context, AsyncTestContext}; + use uuid::Uuid; + + static PDP: OnceLock = OnceLock::new(); + + #[ctor::dtor] + fn cleanup() { + println!("Cleaning up PDP container..."); + if let Some(p) = PDP.get() { + p.cleanup() + } + } + + async fn clear_permit_state(client: &Client) { + println!("Cleaning up Permit state ahead of test..."); + + let users = list_users( + &client.api, + &client.proj_id, + &client.env_id, + None, + None, + None, + None, + Some(100), + ) + .await + .unwrap(); + + for user in users.data { + client.delete_user(&user.id.to_string()).await.unwrap(); + } + + let resources = list_resource_instances( + &client.api, + &client.proj_id, + &client.env_id, + None, + None, + None, + None, + Some(100), + None, + ) + .await + .unwrap(); + + for res in resources { + delete_resource_instance( + &client.api, + &client.proj_id, + &client.env_id, + &res.id.to_string(), + ) + .await + .unwrap(); + } + + println!("Cleaning done."); + } + + struct Wrap(Client); + + impl AsyncTestContext for Wrap { + async fn setup() -> Self { + let api_url = "https://api.eu-central-1.permit.io"; + let api_key = std::env::var("PERMIT_API_KEY") + .expect("PERMIT_API_KEY to be set. You can copy the testing API key from the Testing environment on Permit.io."); + + PDP.get_or_init(|| { + println!("Starting PDP container..."); + DockerInstance::new(&Uuid::new_v4().to_string(), api_url, &api_key) + }); + + let client = Client::new( + api_url.to_owned(), + PDP.get().unwrap().uri.clone(), + // "http://localhost:19716".to_owned(), + "default".to_owned(), + std::env::var("PERMIT_ENV").unwrap_or_else(|_| "testing".to_owned()), + api_key, + ); + + clear_permit_state(&client).await; + + Wrap(client) + } + + async fn teardown(self) {} + } + + #[test_context(Wrap)] + #[tokio::test] + #[serial] + async fn test_user_flow(Wrap(client): &mut Wrap) { + let u = "test_user"; + client.new_user(u).await.unwrap(); + let user = client.get_user(u).await.unwrap(); + + // Can also get user by permit id + let user_by_id = client.get_user(&user.id.to_string()).await.unwrap(); + + assert_eq!(user.id, user_by_id.id); + + client.delete_user(u).await.unwrap(); + let res = client.get_user(u).await; + + assert!(res.is_err()); + } + + #[test_context(Wrap)] + #[tokio::test] + #[serial] + async fn test_tiers_flow(Wrap(client): &mut Wrap) { + let u = "tier_user"; + client.new_user(u).await.unwrap(); + let user = client.get_user(u).await.unwrap(); + + assert_eq!(user.roles.as_ref().unwrap().len(), 1); + assert_eq!( + user.roles.as_ref().unwrap()[0].role, + AccountTier::Basic.to_string() + ); + + client.make_pro(u).await.unwrap(); + let user = client.get_user(u).await.unwrap(); + + assert_eq!(user.roles.as_ref().unwrap().len(), 1); + assert_eq!( + user.roles.as_ref().unwrap()[0].role, + AccountTier::Pro.to_string() + ); + + client.make_basic(u).await.unwrap(); + let user = client.get_user(u).await.unwrap(); + + assert_eq!(user.roles.as_ref().unwrap().len(), 1); + assert_eq!( + user.roles.as_ref().unwrap()[0].role, + AccountTier::Basic.to_string() + ); + } + + #[test_context(Wrap)] + #[tokio::test] + #[serial] + async fn test_projects(Wrap(client): &mut Wrap) { + let u1 = "user1"; + let u2 = "user2"; + 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 p1 = client.get_user_projects(u1).await.unwrap(); + + assert!(p1.is_empty()); + + client.create_project(u1, "proj1").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, "proj1"); + + client.create_project(u1, "proj2").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(), 2); + + client.delete_project("proj1").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, "proj2"); + + let p2 = client.get_user_projects(u2).await.unwrap(); + + assert!(p2.is_empty()); + } +} diff --git a/cargo-shuttle/Cargo.toml b/cargo-shuttle/Cargo.toml index 71429d6d1..ce204c24c 100644 --- a/cargo-shuttle/Cargo.toml +++ b/cargo-shuttle/Cargo.toml @@ -69,7 +69,5 @@ webbrowser = "0.8.2" [dev-dependencies] assert_cmd = "2.0.6" rexpect = "0.5.0" -# Tmp until this branch is merged and released -tokiotest-httpserver = { git = "https://github.com/shuttle-hq/tokiotest-httpserver", branch = "feat/body" } # Publication of this crate will fail if this is changed to a workspace dependency shuttle-common-tests = { path = "../common-tests" } diff --git a/common-tests/src/lib.rs b/common-tests/src/lib.rs index 3d8e8fbb0..3027d9105 100644 --- a/common-tests/src/lib.rs +++ b/common-tests/src/lib.rs @@ -1,5 +1,6 @@ pub mod cargo_shuttle; pub mod logger; +pub mod permit_pdp; pub mod postgres; pub mod provisioner; diff --git a/common-tests/src/permit_pdp.rs b/common-tests/src/permit_pdp.rs new file mode 100644 index 000000000..2dcf419e9 --- /dev/null +++ b/common-tests/src/permit_pdp.rs @@ -0,0 +1,92 @@ +use portpicker::pick_unused_port; +use std::{ + process::Command, + thread::sleep, + time::{Duration, SystemTime}, +}; + +pub struct DockerInstance { + container_name: String, + pub uri: String, +} + +impl DockerInstance { + pub fn new(name: &str, api_url: &str, api_key: &str) -> Self { + 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 port = "7000"; + let image = "docker.io/permitio/pdp-v2:0.2.37"; + let is_ready_cmd = vec![ + "exec", + container_name.as_str(), + "curl", + "-f", + "localhost:7000", + ]; + let host_port = pick_unused_port().unwrap(); + let port_binding = format!("{}:{}", host_port, port); + + let mut args = vec![ + "run", + "--rm", + "--name", + container_name.as_str(), + "-p", + &port_binding, + "-d", + ]; + + args.extend(env.iter().flat_map(|e| ["-e", e])); + + args.push(image); + + Command::new("docker").args(args).spawn().unwrap(); + + Self::wait_ready(Duration::from_secs(120), &is_ready_cmd); + + Self { + container_name, + uri: format!("http://localhost:{host_port}"), + } + } + + fn wait_ready(mut timeout: Duration, is_ready_cmd: &[&str]) { + let mut now = SystemTime::now(); + while !timeout.is_zero() { + let status = Command::new("docker") + .args(is_ready_cmd) + .output() + .unwrap() + .status; + + if status.success() { + println!("{is_ready_cmd:?} succeeded..."); + sleep(Duration::from_millis(350)); + return; + } + + println!("{is_ready_cmd:?} did not succeed this time..."); + sleep(Duration::from_millis(350)); + + timeout = timeout + .checked_sub(now.elapsed().unwrap()) + .unwrap_or_default(); + now = SystemTime::now(); + } + panic!("timed out while waiting for provisioner DB to come up"); + } + + // Remove the docker container. + pub fn cleanup(&self) { + Command::new("docker") + .args(["stop", self.container_name.as_str()]) + .output() + .unwrap(); + Command::new("docker") + .args(["rm", self.container_name.as_str()]) + .output() + .unwrap(); + } +} diff --git a/common-tests/src/postgres.rs b/common-tests/src/postgres.rs index f342b0716..04175027f 100644 --- a/common-tests/src/postgres.rs +++ b/common-tests/src/postgres.rs @@ -15,21 +15,20 @@ use uuid::Uuid; /// /// ``` /// static PG: Lazy = Lazy::new(PostgresDockerInstance::default); - +/// /// #[dtor] /// fn cleanup() { -/// PG.cleanup(); +/// PG.cleanup(); /// } /// -/// #[tokio::test] +/// #[tokio::test] /// async fn test_case() { /// // Get an unique db uri which points to a unique database. /// let db_uri = PG.get_unique_uri(); -/// +/// /// // Test logic below, which can use `db_uri` to connect to the postgres instance. /// } -///``` -/// +/// ``` pub struct DockerInstance { container_name: String, base_uri: String, @@ -37,8 +36,7 @@ pub struct DockerInstance { impl Default for DockerInstance { fn default() -> Self { - let s = Uuid::new_v4().to_string(); - DockerInstance::new(s.as_str()) + DockerInstance::new(Uuid::new_v4().to_string().as_str()) } } diff --git a/common/src/claims.rs b/common/src/claims.rs index 4c18c2139..64eb1be05 100644 --- a/common/src/claims.rs +++ b/common/src/claims.rs @@ -96,12 +96,13 @@ pub enum Scope { Admin, } +#[derive(Default)] pub struct ScopeBuilder(Vec); impl ScopeBuilder { /// Create a builder with the standard scopes for new users. pub fn new() -> Self { - Self(Default::default()) + Self::default() } /// Extend the current scopes with admin scopes. @@ -163,12 +164,6 @@ impl ScopeBuilder { } } -impl Default for ScopeBuilder { - fn default() -> Self { - Self::new() - } -} - #[derive( Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq, Ord, PartialOrd, EnumString, )] diff --git a/common/src/deployment.rs b/common/src/deployment.rs index 38d29ddb5..ef93e39a4 100644 --- a/common/src/deployment.rs +++ b/common/src/deployment.rs @@ -28,21 +28,18 @@ pub struct DeploymentMetadata { } /// The environment this project is running in -#[derive(Clone, Copy, Debug, Display, EnumString, PartialEq, Eq, Serialize, Deserialize)] +#[derive( + Clone, Copy, Debug, Default, Display, EnumString, PartialEq, Eq, Serialize, Deserialize, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum Environment { + #[default] Local, #[strum(serialize = "production")] // Keep this around for a while for backward compat Deployment, } -impl Default for Environment { - fn default() -> Self { - Self::Local - } -} - pub const DEPLOYER_END_MSG_STARTUP_ERR: &str = "Service startup encountered an error"; pub const DEPLOYER_END_MSG_BUILD_ERR: &str = "Service build encountered an error"; pub const DEPLOYER_END_MSG_CRASHED: &str = "Service encountered an error and crashed"; diff --git a/deployer/src/handlers/mod.rs b/deployer/src/handlers/mod.rs index 375fb1ee2..60f903dab 100644 --- a/deployer/src/handlers/mod.rs +++ b/deployer/src/handlers/mod.rs @@ -33,10 +33,9 @@ use shuttle_common::{ }; use shuttle_proto::logger::LogsRequest; -use crate::persistence::{Deployment, Persistence, State}; use crate::{ deployment::{DeploymentManager, Queued}, - persistence::resource::ResourceManager, + persistence::{resource::ResourceManager, Deployment, Persistence, State}, }; pub use {self::error::Error, self::error::Result, self::local::set_jwt_bearer}; diff --git a/deployer/src/persistence/state.rs b/deployer/src/persistence/state.rs index 015820a95..a745e36b5 100644 --- a/deployer/src/persistence/state.rs +++ b/deployer/src/persistence/state.rs @@ -2,7 +2,7 @@ use strum::{Display, EnumString}; use uuid::Uuid; /// States a deployment can be in -#[derive(sqlx::Type, Debug, Display, Clone, Copy, EnumString, PartialEq, Eq)] +#[derive(sqlx::Type, Debug, Default, Display, Clone, Copy, EnumString, PartialEq, Eq)] #[strum(ascii_case_insensitive)] pub enum State { /// Deployment is queued to be build @@ -30,6 +30,7 @@ pub enum State { Crashed, /// We never expect this state and entering this state should be considered a bug + #[default] Unknown, } @@ -39,12 +40,6 @@ pub struct DeploymentState { pub state: State, } -impl Default for State { - fn default() -> Self { - Self::Unknown - } -} - impl From for shuttle_common::deployment::State { fn from(state: State) -> Self { match state { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 631bc4230..ef3b7d0a6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -68,7 +68,9 @@ services: - "--stripe-secret-key=${STRIPE_SECRET_KEY}" # used only for local development - "--jwt-signing-private-key=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1DNENBUUF3QlFZREsyVndCQ0lFSUR5V0ZFYzhKYm05NnA0ZGNLTEwvQWNvVUVsbUF0MVVKSTU4WTc4d1FpWk4KLS0tLS1FTkQgUFJJVkFURSBLRVktLS0tLQo=" - - "--permit-api=https://api.eu-central-1.permit.io" + - "--permit-api-uri=https://api.eu-central-1.permit.io" + - "--permit-pdp-uri=http://permit-pdp:7000" + - "--permit-env=${SHUTTLE_ENV}" - "--permit-api-key=${PERMIT_API_KEY}" otel-collector: ports: diff --git a/docker-compose.yml b/docker-compose.yml index 0f30f5370..72da2bc44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -280,7 +280,7 @@ services: image: docker.io/permitio/pdp-v2:0.2.37 restart: always environment: - - PDP_CONTROL_PLANE=${PERMIT_API} + - PDP_CONTROL_PLANE=https://api.eu-central-1.permit.io - PDP_API_KEY=${PERMIT_API_KEY} ports: - 7000:7000 diff --git a/gateway/src/acme.rs b/gateway/src/acme.rs index 96be153f7..cda8c2d44 100644 --- a/gateway/src/acme.rs +++ b/gateway/src/acme.rs @@ -31,7 +31,7 @@ pub struct AcmeClient(Arc>>); impl AcmeClient { pub fn new() -> Self { - Self(Arc::new(Mutex::new(HashMap::default()))) + Self::default() } async fn add_http01_challenge_authorization(&self, token: String, key: KeyAuthorization) { diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index 59b6524a7..4c3832370 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -176,7 +176,7 @@ async fn create_project( let project = service .create_project( project_name.clone(), - id, + &id, claim.is_admin(), can_create_project, if is_cch_project { @@ -809,6 +809,7 @@ pub(crate) struct RouterState { pub posthog_client: async_posthog::Client, } +#[derive(Default)] pub struct ApiBuilder { router: Router, service: Option>, @@ -817,21 +818,9 @@ pub struct ApiBuilder { bind: Option, } -impl Default for ApiBuilder { - fn default() -> Self { - Self::new() - } -} - impl ApiBuilder { pub fn new() -> Self { - Self { - router: Router::new(), - service: None, - sender: None, - posthog_client: None, - bind: None, - } + Self::default() } pub fn with_acme(mut self, acme: AcmeClient, resolver: Arc) -> Self { @@ -1022,6 +1011,7 @@ pub mod tests { use hyper::body::to_bytes; use hyper::StatusCode; use serde_json::Value; + use shuttle_backends::test_utils::gateway::PermissionsMock; use shuttle_common::claims::AccountTier; use shuttle_common::constants::limits::{MAX_PROJECTS_DEFAULT, MAX_PROJECTS_EXTRA}; use test_context::test_context; @@ -1039,7 +1029,15 @@ pub mod tests { #[tokio::test] async fn api_create_get_delete_projects() -> anyhow::Result<()> { let world = World::new().await; - let service = Arc::new(GatewayService::init(world.args(), world.pool(), "".into()).await?); + let service = Arc::new( + GatewayService::init( + world.args(), + world.pool(), + "".into(), + Box::::default(), + ) + .await?, + ); let (sender, mut receiver) = channel::(256); tokio::spawn(async move { @@ -1221,7 +1219,15 @@ pub mod tests { #[tokio::test] async fn api_create_project_limits() -> anyhow::Result<()> { let world = World::new().await; - let service = Arc::new(GatewayService::init(world.args(), world.pool(), "".into()).await?); + let service = Arc::new( + GatewayService::init( + world.args(), + world.pool(), + "".into(), + Box::::default(), + ) + .await?, + ); let (sender, mut receiver) = channel::(256); tokio::spawn(async move { @@ -1545,9 +1551,14 @@ pub mod tests { async fn status() { let world = World::new().await; let service = Arc::new( - GatewayService::init(world.args(), world.pool(), "".into()) - .await - .unwrap(), + GatewayService::init( + world.args(), + world.pool(), + "".into(), + Box::::default(), + ) + .await + .unwrap(), ); let (sender, mut receiver) = channel::(1); diff --git a/gateway/src/lib.rs b/gateway/src/lib.rs index eb5e471f6..0b3c194aa 100644 --- a/gateway/src/lib.rs +++ b/gateway/src/lib.rs @@ -268,6 +268,7 @@ pub mod tests { use rand::distributions::{Alphanumeric, DistString, Distribution, Uniform}; use ring::signature::{self, Ed25519KeyPair, KeyPair}; use shuttle_backends::auth::ConvertResponse; + use shuttle_backends::test_utils::gateway::PermissionsMock; use shuttle_backends::test_utils::resource_recorder::get_mocked_resource_recorder; use shuttle_common::claims::{AccountTier, Claim}; use shuttle_common::models::deployment::DeploymentRequest; @@ -653,9 +654,14 @@ pub mod tests { /// Create a service and sender to handle tasks. Also starts up a worker to create actual Docker containers for all requests pub async fn service(&self) -> (Arc, Sender) { let service = Arc::new( - GatewayService::init(self.args(), self.pool(), "".into()) - .await - .unwrap(), + GatewayService::init( + self.args(), + self.pool(), + "".into(), + Box::::default(), + ) + .await + .unwrap(), ); let worker = Worker::new(); @@ -1155,9 +1161,14 @@ pub mod tests { async fn end_to_end() { let world = World::new().await; let service = Arc::new( - GatewayService::init(world.args(), world.pool(), "".into()) - .await - .unwrap(), + GatewayService::init( + world.args(), + world.pool(), + "".into(), + Box::::default(), + ) + .await + .unwrap(), ); let worker = Worker::new(); diff --git a/gateway/src/main.rs b/gateway/src/main.rs index c7bd5bdf5..31b562373 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -2,6 +2,7 @@ use async_posthog::ClientOptions; use clap::Parser; use futures::prelude::*; +use shuttle_backends::client::permit; use shuttle_backends::trace::setup_tracing; use shuttle_common::log::Backend; use shuttle_gateway::acme::{AcmeClient, CustomDomain}; @@ -76,7 +77,21 @@ async fn start( posthog_client: async_posthog::Client, args: StartArgs, ) -> io::Result<()> { - let gateway = Arc::new(GatewayService::init(args.context.clone(), db, fs).await?); + let gateway = Arc::new( + GatewayService::init( + args.context.clone(), + db, + fs, + Box::new(permit::Client::new( + args.context.permit_api_uri.to_string(), + args.context.permit_pdp_uri.to_string(), + "default".to_owned(), + args.context.permit_env, + args.context.permit_api_key, + )), + ) + .await?, + ); let worker = Worker::new(); diff --git a/gateway/src/proxy.rs b/gateway/src/proxy.rs index 6e96728c8..9d2959493 100644 --- a/gateway/src/proxy.rs +++ b/gateway/src/proxy.rs @@ -198,6 +198,7 @@ async fn bounce(State(state): State>, req: Request) -> Result Ok(resp.body(body).unwrap()) } +#[derive(Default)] pub struct UserServiceBuilder { service: Option>, task_sender: Option>, @@ -208,23 +209,9 @@ pub struct UserServiceBuilder { public: Option, } -impl Default for UserServiceBuilder { - fn default() -> Self { - Self::new() - } -} - impl UserServiceBuilder { pub fn new() -> Self { - Self { - service: None, - task_sender: None, - public: None, - acme: None, - tls_acceptor: None, - bouncer_binds_to: None, - user_binds_to: None, - } + Self::default() } pub fn with_public(mut self, public: FQDN) -> Self { diff --git a/gateway/src/service.rs b/gateway/src/service.rs index 875a02581..1cde62f67 100644 --- a/gateway/src/service.rs +++ b/gateway/src/service.rs @@ -21,6 +21,7 @@ use instant_acme::{AccountCredentials, ChallengeType}; use once_cell::sync::Lazy; use opentelemetry::global; use opentelemetry_http::HeaderInjector; +use shuttle_backends::client::PermissionsDal; use shuttle_backends::headers::XShuttleAdminSecret; use shuttle_backends::project_name::ProjectName; use shuttle_common::claims::AccountTier; @@ -62,6 +63,7 @@ impl From for Error { } } +#[derive(Default)] pub struct ContainerSettingsBuilder { prefix: Option, image: Option, @@ -73,24 +75,9 @@ pub struct ContainerSettingsBuilder { extra_hosts: Option>, } -impl Default for ContainerSettingsBuilder { - fn default() -> Self { - Self::new() - } -} - impl ContainerSettingsBuilder { pub fn new() -> Self { - Self { - prefix: None, - image: None, - provisioner: None, - auth_uri: None, - resource_recorder_uri: None, - network_name: None, - fqdn: None, - extra_hosts: None, - } + Self::default() } pub async fn from_args(self, args: &ContextArgs) -> ContainerSettings { @@ -204,6 +191,7 @@ pub struct GatewayService { db: SqlitePool, task_router: TaskRouter, pub state_location: PathBuf, + pub permit_client: Box, /// Maximum number of containers the gateway can start before blocking cch projects cch_container_limit: u32, @@ -226,6 +214,7 @@ impl GatewayService { args: ContextArgs, db: SqlitePool, state_location: PathBuf, + permit_client: Box, ) -> io::Result { let docker_stats_path_v1 = PathBuf::from_str(DOCKER_STATS_PATH_CGROUP_V1) .expect("to parse docker stats path for cgroup v1"); @@ -274,6 +263,7 @@ impl GatewayService { db, task_router, state_location, + permit_client, provisioner_host: Endpoint::new(format!("http://{}:8000", args.provisioner_host)) .expect("to have a valid provisioner endpoint"), auth_host: args.auth_uri, @@ -374,10 +364,7 @@ impl GatewayService { Ok(ready_count) } - pub async fn find_project( - &self, - project_name: &ProjectName, - ) -> Result { + pub async fn find_project(&self, project_name: &str) -> Result { query("SELECT project_id, project_state FROM projects WHERE project_name = ?1") .bind(project_name) .fetch_optional(&self.db) @@ -513,7 +500,7 @@ impl GatewayService { pub async fn create_project( &self, project_name: ProjectName, - user_id: UserId, + user_id: &UserId, is_admin: bool, can_create_project: bool, idle_minutes: u64, @@ -526,7 +513,7 @@ impl GatewayService { "#, ) .bind(&project_name) - .bind(&user_id) + .bind(user_id) .bind(is_admin) .fetch_optional(&self.db) .await? @@ -625,7 +612,7 @@ impl GatewayService { &self, project_name: ProjectName, project_id: Ulid, - user_id: UserId, + user_id: &UserId, idle_minutes: u64, ) -> Result { let project = SqlxJson(Project::Creating( @@ -636,14 +623,15 @@ impl GatewayService { ), )); + let mut transaction = self.db.begin().await?; query("INSERT INTO projects (project_id, project_name, account_name, user_id, initial_key, project_state) VALUES (?1, ?2, ?3, ?4, ?5, ?6)") .bind(&project_id.to_string()) .bind(&project_name) .bind("") - .bind(&user_id) + .bind(user_id) .bind(project.initial_key().unwrap()) .bind(&project) - .execute(&self.db) + .execute(&mut *transaction) .await .map_err(|err| { // If the error is a broken PK constraint, this is a @@ -657,6 +645,13 @@ impl GatewayService { err.into() })?; + self.permit_client + .create_project(user_id, &project_id.to_string()) + .await + .map_err(|_| Error::from(ErrorKind::Internal))?; + + transaction.commit().await?; + let project = project.0; Ok(FindProjectPayload { @@ -675,7 +670,7 @@ impl GatewayService { let mut transaction = self.db.begin().await?; query("DELETE FROM custom_domains WHERE project_id = ?1") - .bind(project_id) + .bind(&project_id) .execute(&mut *transaction) .await?; @@ -684,6 +679,11 @@ impl GatewayService { .execute(&mut *transaction) .await?; + self.permit_client + .delete_project(&project_id) + .await + .map_err(|_| Error::from(ErrorKind::Internal))?; + transaction.commit().await?; Ok(()) @@ -1139,6 +1139,7 @@ pub struct FindProjectPayload { #[cfg(test)] pub mod tests { use fqdn::FQDN; + use shuttle_backends::test_utils::gateway::PermissionsMock; use super::*; @@ -1147,9 +1148,18 @@ pub mod tests { use crate::{Error, ErrorKind}; #[tokio::test] - async fn service_create_find_stop_delete_project() -> anyhow::Result<()> { + async fn service_create_find_stop_delete_project() { let world = World::new().await; - let svc = Arc::new(GatewayService::init(world.args(), world.pool(), "".into()).await?); + let svc = Arc::new( + GatewayService::init( + world.args(), + world.pool(), + "".into(), + Box::::default(), + ) + .await + .unwrap(), + ); let neo: UserId = "neo".to_owned(); let trinity: UserId = "trinity".to_owned(); @@ -1165,7 +1175,7 @@ pub mod tests { }; let project = svc - .create_project(matrix.clone(), neo.clone(), false, true, 0) + .create_project(matrix.clone(), &neo, false, true, 0) .await .unwrap(); @@ -1198,14 +1208,14 @@ pub mod tests { // Test project pagination, first create 20 projects. for p in (0..20).map(|p| format!("matrix-{p}")) { - svc.create_project(p.parse().unwrap(), admin.clone(), true, true, 0) + svc.create_project(p.parse().unwrap(), &admin, true, true, 0) .await .unwrap(); } // Creating a project with can_create_project set to false should fail. assert_eq!( - svc.create_project("final-one".parse().unwrap(), admin.clone(), false, false, 0) + svc.create_project("final-one".parse().unwrap(), &admin, false, false, 0) .await .err() .unwrap() @@ -1272,7 +1282,7 @@ pub mod tests { // If recreated by a different user assert!(matches!( - svc.create_project(matrix.clone(), trinity.clone(), false, true, 0) + svc.create_project(matrix.clone(), &trinity, false, true, 0) .await, Err(Error { kind: ErrorKind::ProjectAlreadyExists, @@ -1282,7 +1292,7 @@ pub mod tests { // If recreated by the same user assert!(matches!( - svc.create_project(matrix.clone(), neo.clone(), false, true, 0) + svc.create_project(matrix.clone(), &neo, false, true, 0) .await, Ok(FindProjectPayload { project_id: _, @@ -1292,7 +1302,7 @@ pub mod tests { // If recreated by the same user again while it's running assert!(matches!( - svc.create_project(matrix.clone(), neo.clone(), false, true, 0) + svc.create_project(matrix.clone(), &neo, false, true, 0) .await, Err(Error { kind: ErrorKind::OwnProjectAlreadyExists(_), @@ -1320,7 +1330,7 @@ pub mod tests { // If recreated by an admin assert!(matches!( - svc.create_project(matrix.clone(), admin.clone(), true, true, 0) + svc.create_project(matrix.clone(), &admin, true, true, 0) .await, Ok(FindProjectPayload { project_id: _, @@ -1330,7 +1340,7 @@ pub mod tests { // If recreated by an admin again while it's running assert!(matches!( - svc.create_project(matrix.clone(), admin.clone(), true, true, 0) + svc.create_project(matrix.clone(), &admin, true, true, 0) .await, Err(Error { kind: ErrorKind::OwnProjectAlreadyExists(_), @@ -1352,25 +1362,32 @@ pub mod tests { // It can be re-created by anyone, with the same project name assert!(matches!( - svc.create_project(matrix, trinity.clone(), false, true, 0) - .await, + svc.create_project(matrix, &trinity, false, true, 0).await, Ok(FindProjectPayload { project_id: _, state: Project::Creating(_), }) )); - Ok(()) } #[tokio::test] - async fn service_create_ready_kill_restart_docker() -> anyhow::Result<()> { + async fn service_create_ready_kill_restart_docker() { let world = World::new().await; - let svc = Arc::new(GatewayService::init(world.args(), world.pool(), "".into()).await?); + let svc = Arc::new( + GatewayService::init( + world.args(), + world.pool(), + "".into(), + Box::::default(), + ) + .await + .unwrap(), + ); let neo: UserId = "neo".to_owned(); let matrix: ProjectName = "matrix".parse().unwrap(); - svc.create_project(matrix.clone(), neo.clone(), false, true, 0) + svc.create_project(matrix.clone(), &neo, false, true, 0) .await .unwrap(); @@ -1410,14 +1427,21 @@ pub mod tests { let project = svc.find_project(&matrix).await.unwrap(); println!("{:?}", project.state); assert!(project.state.is_ready()); - - Ok(()) } #[tokio::test] - async fn service_create_find_custom_domain() -> anyhow::Result<()> { + async fn service_create_find_custom_domain() { let world = World::new().await; - let svc = Arc::new(GatewayService::init(world.args(), world.pool(), "".into()).await?); + let svc = Arc::new( + GatewayService::init( + world.args(), + world.pool(), + "".into(), + Box::::default(), + ) + .await + .unwrap(), + ); let account: UserId = "neo".to_owned(); let project_name: ProjectName = "matrix".parse().unwrap(); @@ -1431,7 +1455,7 @@ pub mod tests { ); let _ = svc - .create_project(project_name.clone(), account.clone(), false, true, 0) + .create_project(project_name.clone(), &account, false, true, 0) .await .unwrap(); @@ -1464,14 +1488,21 @@ pub mod tests { assert_eq!(custom_domain.project_name, project_name); assert_eq!(custom_domain.certificate, certificate); assert_eq!(custom_domain.private_key, private_key); - - Ok(()) } #[tokio::test] - async fn service_create_custom_domain_destroy_recreate_project() -> anyhow::Result<()> { + async fn service_create_custom_domain_destroy_recreate_project() { let world = World::new().await; - let svc = Arc::new(GatewayService::init(world.args(), world.pool(), "".into()).await?); + let svc = Arc::new( + GatewayService::init( + world.args(), + world.pool(), + "".into(), + Box::::default(), + ) + .await + .unwrap(), + ); let account: UserId = "neo".to_owned(); let project_name: ProjectName = "matrix".parse().unwrap(); @@ -1485,7 +1516,7 @@ pub mod tests { ); let _ = svc - .create_project(project_name.clone(), account.clone(), false, true, 0) + .create_project(project_name.clone(), &account, false, true, 0) .await .unwrap(); @@ -1503,12 +1534,10 @@ pub mod tests { assert!(matches!(work.poll(()).await, TaskResult::Done(()))); let recreated_project = svc - .create_project(project_name.clone(), account.clone(), false, true, 0) + .create_project(project_name.clone(), &account, false, true, 0) .await .unwrap(); assert!(matches!(recreated_project.state, Project::Creating(_))); - - Ok(()) } } diff --git a/gateway/src/tls.rs b/gateway/src/tls.rs index 5d1a304c4..d205cdff3 100644 --- a/gateway/src/tls.rs +++ b/gateway/src/tls.rs @@ -86,23 +86,15 @@ impl ChainAndPrivateKey { } } +#[derive(Default)] pub struct GatewayCertResolver { keys: RwLock>>, default: RwLock>>, } -impl Default for GatewayCertResolver { - fn default() -> Self { - Self::new() - } -} - impl GatewayCertResolver { pub fn new() -> Self { - Self { - keys: RwLock::new(HashMap::default()), - default: RwLock::new(None), - } + Self::default() } /// Get the loaded [CertifiedKey] associated with the given