diff --git a/backends/tests/integration/permit_tests.rs b/backends/tests/integration/permit_tests.rs index 65731ef09..410d3e144 100644 --- a/backends/tests/integration/permit_tests.rs +++ b/backends/tests/integration/permit_tests.rs @@ -90,7 +90,6 @@ mod needs_docker { 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, @@ -100,8 +99,6 @@ mod needs_docker { Wrap(client) } - - async fn teardown(self) {} } #[test_context(Wrap)] diff --git a/docker-compose.yml b/docker-compose.yml index 18f2aa654..14f33e947 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -282,6 +282,8 @@ services: environment: - PDP_CONTROL_PLANE=https://api.eu-central-1.permit.io - PDP_API_KEY=${PERMIT_API_KEY} + # Querying users with lots of resource instances takes more than the default 1s + - PDP_OPA_CLIENT_QUERY_TIMEOUT=10 ports: - 7000:7000 networks: diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index 1481bf10e..59f7443f0 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -104,11 +104,11 @@ async fn get_project( State(RouterState { service, .. }): State, ScopedUser { scope, .. }: ScopedUser, ) -> Result, Error> { - let project = service.find_project(&scope).await?; + let project = service.find_project_by_name(&scope).await?; let idle_minutes = project.state.idle_minutes(); let response = project::Response { - id: project.project_id.to_uppercase(), + id: project.id.to_uppercase(), name: scope.to_string(), state: project.state.into(), idle_minutes, @@ -127,25 +127,31 @@ async fn check_project_name( .await .map(AxumJson) } - async fn get_projects_list( State(RouterState { service, .. }): State, - User { id: name, .. }: User, - Query(PaginationDetails { page, limit }): Query, + User { id, .. }: User, ) -> Result>, Error> { - let limit = limit.unwrap_or(u32::MAX); - let page = page.unwrap_or(0); - let projects = service - // The `offset` is page size * amount of pages - .iter_user_projects_detailed(&name, limit * page, limit) - .await? - .map(|project| project::Response { - id: project.0.to_uppercase(), - name: project.1.to_string(), - idle_minutes: project.2.idle_minutes(), - state: project.2.into(), - }) - .collect(); + let mut projects = vec![]; + for p in service + .permit_client + .get_user_projects(&id) + .await + .map_err(|_| Error::from(ErrorKind::Internal))? + { + let proj_id = p.resource.expect("project resource").key; + let project = service.find_project_by_id(&proj_id).await?; + let idle_minutes = project.state.idle_minutes(); + + let response = project::Response { + id: project.id, + name: project.name, + state: project.state.into(), + idle_minutes, + }; + projects.push(response); + } + // sort by descending id + projects.sort_by(|p1, p2| p2.id.cmp(&p1.id)); Ok(AxumJson(projects)) } @@ -197,7 +203,7 @@ async fn create_project( .await?; let response = project::Response { - id: project.project_id.to_string().to_uppercase(), + id: project.id.to_string().to_uppercase(), name: project_name.to_string(), state: project.state.into(), idle_minutes, @@ -216,11 +222,11 @@ async fn destroy_project( .. }: ScopedUser, ) -> Result, Error> { - let project = service.find_project(&project_name).await?; + let project = service.find_project_by_name(&project_name).await?; let idle_minutes = project.state.idle_minutes(); let mut response = project::Response { - id: project.project_id.to_uppercase(), + id: project.id.to_uppercase(), name: project_name.to_string(), state: project.state.into(), idle_minutes, @@ -264,10 +270,9 @@ async fn delete_project( } let project_name = scoped_user.scope.clone(); - let project = state.service.find_project(&project_name).await?; + let project = state.service.find_project_by_name(&project_name).await?; - let project_id = - Ulid::from_string(&project.project_id).expect("stored project id to be a valid ULID"); + let project_id = Ulid::from_string(&project.id).expect("stored project id to be a valid ULID"); // Try to startup destroyed, errored or outdated projects let project_deletable = project.state.is_ready() || project.state.is_stopped(); @@ -307,7 +312,7 @@ async fn delete_project( // Wait for the project to be ready handle.await; - let new_state = state.service.find_project(&project_name).await?; + let new_state = state.service.find_project_by_name(&project_name).await?; if !new_state.state.is_ready() { return Err(Error::from_kind(ErrorKind::ProjectCorrupted)); @@ -391,7 +396,7 @@ async fn override_create_service( scoped_user: ScopedUser, req: Request, ) -> Result, Error> { - let user_id = scoped_user.user.claim.sub.clone(); + let user_id = scoped_user.user.id.clone(); let posthog_client = state.posthog_client.clone(); tokio::spawn(async move { let event = async_posthog::Event::new("shuttle_api_start_deployment", &user_id); diff --git a/gateway/src/auth.rs b/gateway/src/auth.rs index ac818a266..81d4054a7 100644 --- a/gateway/src/auth.rs +++ b/gateway/src/auth.rs @@ -4,10 +4,11 @@ use axum::extract::{FromRef, FromRequestParts, Path}; use axum::http::request::Parts; use serde::{Deserialize, Serialize}; use shuttle_backends::project_name::ProjectName; -use shuttle_common::claims::{Claim, Scope}; +use shuttle_backends::ClaimExt; +use shuttle_common::claims::Claim; use shuttle_common::models::error::InvalidProjectName; use shuttle_common::models::user::UserId; -use tracing::{trace, Span}; +use tracing::{error, trace, Span}; use crate::api::latest::RouterState; use crate::{Error, ErrorKind}; @@ -19,7 +20,6 @@ use crate::{Error, ErrorKind}; /// is valid against the user's owned resources. #[derive(Clone, Deserialize, PartialEq, Eq, Serialize, Debug)] pub struct User { - pub projects: Vec, pub claim: Claim, pub id: UserId, } @@ -32,18 +32,15 @@ where { type Rejection = Error; - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let claim = parts.extensions.get::().ok_or(ErrorKind::Internal)?; let user_id = claim.sub.clone(); // Record current account name for tracing purposes Span::current().record("account.user_id", &user_id); - let RouterState { service, .. } = RouterState::from_ref(state); - let user = User { claim: claim.clone(), - projects: service.iter_user_projects(&user_id).await?.collect(), id: user_id, }; @@ -83,7 +80,43 @@ where .map_err(|_| Error::from(ErrorKind::InvalidProjectName(InvalidProjectName)))?, }; - if user.projects.contains(&scope) || user.claim.scopes.contains(&Scope::Admin) { + let RouterState { service, .. } = RouterState::from_ref(state); + + let has_bypass = user.claim.is_admin() || user.claim.is_deployer(); + + let allowed = has_bypass + || { + let projects: Vec<_> = service.iter_user_projects(&user.id).await?.collect(); + let internal_allowed = projects.contains(&scope); + + let permit_allowed = service + .permit_client + .allowed( + &user.id, + &service.find_project_by_name(&scope).await?.id, + "develop", // TODO?: make this configurable per endpoint? + ) + .await + .map_err(|_| { + error!("failed to check Permit permission"); + // Error::from_kind(ErrorKind::Internal) + }) + .unwrap_or_default(); + + if internal_allowed != permit_allowed { + error!( + "PERMIT: Permissions for user {} project {} did not match internal permissions. Internal: {}, Permit: {}", + user.id, + scope, + internal_allowed, + permit_allowed + ); + } + + internal_allowed + }; + + if allowed { Ok(Self { user, scope }) } else { Err(Error::from(ErrorKind::ProjectNotFound(scope.to_string()))) diff --git a/gateway/src/project.rs b/gateway/src/project.rs index c64c72664..01cbd6d60 100644 --- a/gateway/src/project.rs +++ b/gateway/src/project.rs @@ -1845,7 +1845,12 @@ pub mod exec { .await .expect("could not list projects") { - match gateway.find_project(&project_name).await.unwrap().state { + match gateway + .find_project_by_name(&project_name) + .await + .unwrap() + .state + { Project::Errored(ProjectError { ctx: Some(ctx), .. }) => { if let Some(container) = ctx.container() { if let Ok(container) = gateway @@ -1938,8 +1943,11 @@ pub mod exec { .await .expect("could not list cch projects") { - if let Project::Ready(ProjectReady { container, .. }) = - gateway.find_project(&project_name).await.unwrap().state + if let Project::Ready(ProjectReady { container, .. }) = gateway + .find_project_by_name(&project_name) + .await + .unwrap() + .state { if let Ok(container) = gateway .context() diff --git a/gateway/src/service.rs b/gateway/src/service.rs index dfbf57eac..ae7aa1587 100644 --- a/gateway/src/service.rs +++ b/gateway/src/service.rs @@ -364,13 +364,43 @@ impl GatewayService { Ok(ready_count) } - 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) + pub async fn find_project_by_name( + &self, + project_name: &str, + ) -> Result { + query( + "SELECT project_id, project_name, project_state FROM projects WHERE project_name = ?1", + ) + .bind(project_name) + .fetch_optional(&self.db) + .await? + .map(|r| FindProjectPayload { + id: r.get("project_id"), + name: r.get("project_name"), + state: r + .try_get::, _>("project_state") + .map(|p| p.0) + .unwrap_or_else(|err| { + error!( + error = &err as &dyn std::error::Error, + "Failed to deser `project_state`" + ); + Project::Errored(ProjectError::internal( + "Error when trying to deserialize state of project.", + )) + }), + }) + .ok_or_else(|| Error::from_kind(ErrorKind::ProjectNotFound(project_name.to_string()))) + } + + pub async fn find_project_by_id(&self, project_id: &str) -> Result { + query("SELECT project_id, project_name, project_state FROM projects WHERE project_id = ?1") + .bind(project_id) .fetch_optional(&self.db) .await? .map(|r| FindProjectPayload { - project_id: r.get("project_id"), + id: r.get("project_id"), + name: r.get("project_name"), state: r .try_get::, _>("project_state") .map(|p| p.0) @@ -384,7 +414,7 @@ impl GatewayService { )) }), }) - .ok_or_else(|| Error::from_kind(ErrorKind::ProjectNotFound(project_name.to_string()))) + .ok_or_else(|| Error::from_kind(ErrorKind::ProjectNotFound(project_id.to_string()))) } pub async fn project_name_exists(&self, project_name: &ProjectName) -> Result { @@ -547,7 +577,8 @@ impl GatewayService { let project = Project::Creating(creating); self.update_project(&project_name, &project).await?; Ok(FindProjectPayload { - project_id, + id: project_id, + name: project_name.to_string(), state: project, }) } else { @@ -655,7 +686,8 @@ impl GatewayService { let project = project.0; Ok(FindProjectPayload { - project_id: project_id.to_string(), + id: project_id.to_string(), + name: project_name.to_string(), state: project, }) } @@ -875,7 +907,7 @@ impl GatewayService { project_name: &ProjectName, task_sender: Sender, ) -> Result<(FindProjectPayload, bool), Error> { - let mut project = self.find_project(project_name).await?; + let mut project = self.find_project_by_name(project_name).await?; // Start the project if it is idle let is_stopped = project.state.is_stopped(); @@ -893,7 +925,7 @@ impl GatewayService { // Wait for project to come up and set new state handle.await; - project = self.find_project(project_name).await?; + project = self.find_project_by_name(project_name).await?; } Ok((project, is_stopped)) @@ -1132,7 +1164,8 @@ impl GatewayContext { } pub struct FindProjectPayload { - pub project_id: String, + pub id: String, + pub name: String, pub state: Project, } @@ -1182,7 +1215,7 @@ pub mod tests { assert!(creating_same_project_name(&project.state, &matrix)); assert_eq!( - svc.find_project(&matrix).await.unwrap().state, + svc.find_project_by_name(&matrix).await.unwrap().state, project.state ); assert_eq!( @@ -1273,11 +1306,8 @@ pub mod tests { // After project has been destroyed... assert!(matches!( - svc.find_project(&matrix).await, - Ok(FindProjectPayload { - project_id: _, - state: Project::Destroyed(_), - }) + svc.find_project_by_name(&matrix).await.unwrap().state, + Project::Destroyed(_) )); // If recreated by a different user @@ -1293,11 +1323,10 @@ pub mod tests { // If recreated by the same user assert!(matches!( svc.create_project(matrix.clone(), &neo, false, true, 0) - .await, - Ok(FindProjectPayload { - project_id: _, - state: Project::Creating(_), - }) + .await + .unwrap() + .state, + Project::Creating(_), )); // If recreated by the same user again while it's running @@ -1321,21 +1350,17 @@ pub mod tests { // After project has been destroyed again... assert!(matches!( - svc.find_project(&matrix).await, - Ok(FindProjectPayload { - project_id: _, - state: Project::Destroyed(_), - }) + svc.find_project_by_name(&matrix).await.unwrap().state, + Project::Destroyed(_), )); // If recreated by an admin assert!(matches!( svc.create_project(matrix.clone(), &admin, true, true, 0) - .await, - Ok(FindProjectPayload { - project_id: _, - state: Project::Creating(_), - }) + .await + .unwrap() + .state, + Project::Creating(_) )); // If recreated by an admin again while it's running @@ -1353,7 +1378,7 @@ pub mod tests { // Project is gone assert!(matches!( - svc.find_project(&matrix).await, + svc.find_project_by_name(&matrix).await, Err(Error { kind: ErrorKind::ProjectNotFound(_), .. @@ -1362,11 +1387,11 @@ pub mod tests { // It can be re-created by anyone, with the same project name assert!(matches!( - svc.create_project(matrix, &trinity, false, true, 0).await, - Ok(FindProjectPayload { - project_id: _, - state: Project::Creating(_), - }) + svc.create_project(matrix, &trinity, false, true, 0) + .await + .unwrap() + .state, + Project::Creating(_) )); } @@ -1397,7 +1422,7 @@ pub mod tests { // keep polling } - let project = svc.find_project(&matrix).await.unwrap(); + let project = svc.find_project_by_name(&matrix).await.unwrap(); println!("{:?}", project.state); assert!(project.state.is_ready()); @@ -1415,7 +1440,7 @@ pub mod tests { // the first poll will trigger a refresh let _ = ambulance_task.poll(()).await; - let project = svc.find_project(&matrix).await.unwrap(); + let project = svc.find_project_by_name(&matrix).await.unwrap(); println!("{:?}", project.state); assert!(!project.state.is_ready()); @@ -1424,7 +1449,7 @@ pub mod tests { // keep polling } - let project = svc.find_project(&matrix).await.unwrap(); + let project = svc.find_project_by_name(&matrix).await.unwrap(); println!("{:?}", project.state); assert!(project.state.is_ready()); } diff --git a/gateway/src/task.rs b/gateway/src/task.rs index 0c60725d6..bb83265ad 100644 --- a/gateway/src/task.rs +++ b/gateway/src/task.rs @@ -469,7 +469,7 @@ impl Task<()> for ProjectTask { let ctx = self.service.context().clone(); - let project = match self.service.find_project(&self.project_name).await { + let project = match self.service.find_project_by_name(&self.project_name).await { Ok(project) => project, Err(err) => return TaskResult::Err(err), };