diff --git a/backends/src/client/permit.rs b/backends/src/client/permit.rs index 11f009c08..f3c84cbbc 100644 --- a/backends/src/client/permit.rs +++ b/backends/src/client/permit.rs @@ -4,13 +4,17 @@ use async_trait::async_trait; use http::StatusCode; use permit_client_rs::{ apis::{ + relationship_tuples_api::{ + create_relationship_tuple, delete_relationship_tuple, list_relationship_tuples, + }, resource_instances_api::{create_resource_instance, delete_resource_instance}, role_assignments_api::{assign_role, unassign_role}, users_api::{create_user, delete_user, get_user}, Error as PermitClientError, }, models::{ - ResourceInstanceCreate, RoleAssignmentCreate, RoleAssignmentRemove, UserCreate, UserRead, + RelationshipTupleCreate, RelationshipTupleDelete, ResourceInstanceCreate, + RoleAssignmentCreate, RoleAssignmentRemove, UserCreate, UserRead, }, }; use permit_pdp_client_rs::{ @@ -24,7 +28,8 @@ use permit_pdp_client_rs::{ }, models::{AuthorizationQuery, Resource, User, UserPermissionsQuery, UserPermissionsResult}, }; -use shuttle_common::claims::AccountTier; +use serde::{Deserialize, Serialize}; +use shuttle_common::{claims::AccountTier, models::organization}; #[async_trait] pub trait PermissionsDal { @@ -50,7 +55,37 @@ pub trait PermissionsDal { // Organization management - ////// TODO + /// Creates an Organization resource and assigns the user as admin for the organization + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error>; + + /// Deletes an Organization resource + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error>; + + /// Get a list of all the organizations a user has access to + async fn get_organizations(&self, user_id: &str) -> Result, Error>; + + /// Get a list of all project IDs that belong to an organization + async fn get_organization_projects( + &self, + user_id: &str, + org_id: &str, + ) -> Result, Error>; + + /// Transfers a project from a users to an organization + async fn transfer_project_to_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<(), Error>; + + /// Transfers a project from an organization to a user + async fn transfer_project_from_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<(), Error>; // Permissions queries @@ -60,6 +95,30 @@ pub trait PermissionsDal { async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result; } +/// Simple details of an organization to create +#[derive(Debug, PartialEq)] +pub struct Organization { + /// Unique identifier for the organization. Should be `org_{ulid}` + pub id: String, + + /// The name used to display the organization in the UI + pub display_name: String, +} + +#[derive(Deserialize, Serialize)] +/// The attributes stored with each organization resource +struct OrganizationAttributes { + display_name: String, +} + +impl OrganizationAttributes { + fn new(org: &Organization) -> Self { + Self { + display_name: org.display_name.to_string(), + } + } +} + /// Wrapper for the Permit.io API and PDP (Policy decision point) API #[derive(Clone)] pub struct Client { @@ -232,132 +291,218 @@ impl PermissionsDal for Client { Ok(res.allow.unwrap_or_default()) } -} -// 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?; + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error> { + if !self.allowed_org(user_id, &org.id, "create").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: + "User does not have permission to create organization. Are you a pro user?" + .to_owned(), + entity: "Organization".to_owned(), + })); + } - // self.api - // .post( - // &format!("{}/role_assignments", self.facts), - // json!({ - // "role": "admin", - // "resource_instance": format!("Organization:{org_name}"), - // "tenant": "default", - // "user": user_id, - // }), - // None, - // ) - // .await - // } + if !self.get_organizations(user_id).await?.is_empty() { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::BAD_REQUEST, + content: "User already has an organization".to_owned(), + entity: "Organization".to_owned(), + })); + } - // 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 - // } + if let Err(e) = create_resource_instance( + &self.api, + &self.proj_id, + &self.env_id, + ResourceInstanceCreate { + key: org.id.to_owned(), + tenant: "default".to_owned(), + resource: "Organization".to_owned(), + attributes: serde_json::to_value(OrganizationAttributes::new(org)).ok(), + }, + ) + .await + { + // Early return all errors except 409's (project already exists) + let e: Error = e.into(); + if let Error::ResponseError(ref re) = e { + if re.status != StatusCode::CONFLICT { + return Err(e); + } + } else { + return Err(e); + } + } - // 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 - // } + self.assign_resource_role(user_id, format!("Organization:{}", org.id), "admin") + .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(()) + } - // Ok(res[0].as_object().unwrap()["role"].as_str().unwrap() == "admin") - // } + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error> { + if !self.allowed_org(user_id, org_id, "manage").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to delete the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } - // 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 - // } + let projects = self.get_organization_projects(user_id, org_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 !projects.is_empty() { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::BAD_REQUEST, + content: "Organization still has projects".to_owned(), + entity: "Organization".to_owned(), + })); + } - // 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 - // } + Ok(delete_resource_instance( + &self.api, + &self.proj_id, + &self.env_id, + format!("Organization:{org_id}").as_str(), + ) + .await?) + } + + async fn get_organization_projects( + &self, + user_id: &str, + org_id: &str, + ) -> Result, Error> { + if !self.allowed_org(user_id, org_id, "view").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to view the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + let relationships = list_relationship_tuples( + &self.api, + &self.proj_id, + &self.env_id, + Some(true), + None, + None, + Some("default"), + Some(&format!("Organization:{org_id}")), + Some("parent"), + None, + Some("Project"), + None, + ) + .await?; + + let mut projects = Vec::with_capacity(relationships.len()); + + for rel in relationships { + projects.push(rel.object_details.expect("to have object details").key); + } + + Ok(projects) + } + + async fn get_organizations(&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() + }), + resource_types: Some(vec!["Organization".to_owned()]), + tenants: Some(vec!["default".to_owned()]), + ..Default::default() + }, + None, + None, + ) + .await?; + + let mut res = Vec::with_capacity(perms.len()); + + for perm in perms.into_values() { + if let Some(resource) = perm.resource { + let attributes = resource.attributes.unwrap_or_default(); + let org = serde_json::from_value::(attributes) + .expect("to read organization attributes"); + + res.push(organization::Response { + id: resource.key, + display_name: org.display_name, + is_admin: perm + .roles + .unwrap_or_default() + .contains(&"admin".to_string()), + }); + } + } + + Ok(res) + } + + async fn transfer_project_to_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<(), Error> { + if !self.allowed_org(user_id, org_id, "manage").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to modify the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + self.unassign_resource_role(user_id, format!("Project:{project_id}"), "admin") + .await?; + + self.assign_relationship( + format!("Organization:{org_id}"), + "parent", + format!("Project:{project_id}"), + ) + .await?; + + Ok(()) + } + async fn transfer_project_from_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<(), Error> { + if !self.allowed_org(user_id, org_id, "manage").await? { + return Err(Error::ResponseError(ResponseContent { + status: StatusCode::FORBIDDEN, + content: "User does not have permission to modify the organization".to_owned(), + entity: "Organization".to_owned(), + })); + } + + self.assign_resource_role(user_id, format!("Project:{project_id}"), "admin") + .await?; + + self.unassign_relationship( + format!("Organization:{org_id}"), + "parent", + format!("Project:{project_id}"), + ) + .await?; + + Ok(()) + } +} + +// Helpers for trait methods +impl Client { // pub async fn get_organization_members(&self, org_name: &str) -> Result, Error> { // self.api // .get( @@ -477,7 +622,7 @@ impl Client { Ok(()) } - async fn _unassign_resource_role( + async fn unassign_resource_role( &self, user_id: &str, resource_instance: String, @@ -499,6 +644,75 @@ impl Client { Ok(()) } + async fn allowed_org(&self, user_id: &str, org_id: &str, action: &str) -> Result { + // NOTE: This API function was modified in upstream to use AuthorizationQuery + let res = is_allowed_allowed_post( + &self.pdp, + AuthorizationQuery { + user: Box::new(User { + key: user_id.to_owned(), + ..Default::default() + }), + action: action.to_owned(), + resource: Box::new(Resource { + r#type: "Organization".to_string(), + key: Some(org_id.to_owned()), + tenant: Some("default".to_owned()), + ..Default::default() + }), + ..Default::default() + }, + None, + None, + ) + .await?; + + Ok(res.allow.unwrap_or_default()) + } + + async fn assign_relationship( + &self, + subject: String, + role: &str, + object: String, + ) -> Result<(), Error> { + create_relationship_tuple( + &self.api, + &self.proj_id, + &self.env_id, + RelationshipTupleCreate { + relation: role.to_owned(), + tenant: Some("default".to_owned()), + subject, + object, + }, + ) + .await?; + + Ok(()) + } + + async fn unassign_relationship( + &self, + subject: String, + role: &str, + object: String, + ) -> Result<(), Error> { + delete_relationship_tuple( + &self.api, + &self.proj_id, + &self.env_id, + RelationshipTupleDelete { + relation: role.to_owned(), + subject, + object, + }, + ) + .await?; + + Ok(()) + } + pub async fn sync_pdp(&self) -> Result<(), Error> { trigger_policy_update_policy_updater_trigger_post(&self.pdp).await?; trigger_policy_data_update_data_updater_trigger_post(&self.pdp).await?; diff --git a/backends/src/test_utils/gateway.rs b/backends/src/test_utils/gateway.rs index fe026fece..192ba916f 100644 --- a/backends/src/test_utils/gateway.rs +++ b/backends/src/test_utils/gateway.rs @@ -4,6 +4,7 @@ use async_trait::async_trait; use permit_client_rs::models::UserRead; use permit_pdp_client_rs::models::UserPermissionsResult; use serde::Serialize; +use shuttle_common::models::organization; use tokio::sync::Mutex; use wiremock::{ http, @@ -11,7 +12,10 @@ use wiremock::{ Mock, MockServer, Request, ResponseTemplate, }; -use crate::client::{permit::Error, PermissionsDal}; +use crate::client::{ + permit::{Error, Organization}, + PermissionsDal, +}; pub async fn get_mocked_gateway_server() -> MockServer { let mock_server = MockServer::start().await; @@ -159,4 +163,64 @@ impl PermissionsDal for PermissionsMock { .push(format!("allowed {user_id} {project_id} {action}")); Ok(true) } + + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error> { + self.calls.lock().await.push(format!( + "create_organization {user_id} {} {}", + org.id, org.display_name + )); + Ok(()) + } + + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error> { + self.calls + .lock() + .await + .push(format!("delete_organization {user_id} {org_id}")); + Ok(()) + } + + async fn get_organization_projects( + &self, + user_id: &str, + org_id: &str, + ) -> Result, Error> { + self.calls + .lock() + .await + .push(format!("get_organization_projects {user_id} {org_id}")); + Ok(Default::default()) + } + + async fn get_organizations(&self, user_id: &str) -> Result, Error> { + self.calls + .lock() + .await + .push(format!("get_organizations {user_id}")); + Ok(Default::default()) + } + + async fn transfer_project_to_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<(), Error> { + self.calls.lock().await.push(format!( + "transfer_project_to_org {user_id} {project_id} {org_id}" + )); + Ok(()) + } + + async fn transfer_project_from_org( + &self, + user_id: &str, + project_id: &str, + org_id: &str, + ) -> Result<(), Error> { + self.calls.lock().await.push(format!( + "transfer_project_from_org {user_id} {project_id} {org_id}" + )); + Ok(()) + } } diff --git a/backends/tests/integration/permit_tests.rs b/backends/tests/integration/permit_tests.rs index 410d3e144..c1f513ec9 100644 --- a/backends/tests/integration/permit_tests.rs +++ b/backends/tests/integration/permit_tests.rs @@ -8,10 +8,10 @@ mod needs_docker { }; use serial_test::serial; use shuttle_backends::client::{ - permit::{Client, Error, ResponseContent}, + permit::{Client, Error, Organization, ResponseContent}, PermissionsDal, }; - use shuttle_common::claims::AccountTier; + use shuttle_common::{claims::AccountTier, models::organization}; use shuttle_common_tests::permit_pdp::DockerInstance; use test_context::{test_context, AsyncTestContext}; use uuid::Uuid; @@ -199,4 +199,154 @@ mod needs_docker { assert!(p2.is_empty()); } + + #[test_context(Wrap)] + #[tokio::test] + #[serial] + async fn test_organizations(Wrap(client): &mut Wrap) { + let u1 = "user-o-1"; + let u2 = "user-o-2"; + client.new_user(u1).await.unwrap(); + client.new_user(u2).await.unwrap(); + + const SLEEP: u64 = 500; + + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + let org = Organization { + id: "org_123".to_string(), + display_name: "Test organization".to_string(), + }; + + let err = client.create_organization(u1, &org).await.unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Only Pro users can create organizations" + ); + + client.make_pro(u1).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + + client.create_organization(u1, &org).await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let o1 = client.get_organizations(u1).await.unwrap(); + + assert_eq!( + o1, + vec![organization::Response { + id: "org_123".to_string(), + display_name: "Test organization".to_string(), + is_admin: true, + }] + ); + + let err = client + .create_organization( + u1, + &Organization { + id: "org_987".to_string(), + display_name: "Second organization".to_string(), + }, + ) + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::BAD_REQUEST), + "User cannot create more than one organization" + ); + + client.create_project(u1, "proj-o-1").await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let p1 = client.get_user_projects(u1).await.unwrap(); + + assert_eq!(p1.len(), 1); + assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1"); + + client + .transfer_project_to_org(u1, "proj-o-1", "org_123") + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let p1 = client.get_user_projects(u1).await.unwrap(); + + assert_eq!(p1.len(), 1); + assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1"); + + let err = client + .get_organization_projects(u2, "org_123") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "User cannot view projects on an organization it does not belong to" + ); + + let ps = client + .get_organization_projects(u1, "org_123") + .await + .unwrap(); + assert_eq!(ps, vec!["proj-o-1"]); + + client.create_project(u2, "proj-o-2").await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let p2 = client.get_user_projects(u2).await.unwrap(); + + assert_eq!(p2.len(), 1); + assert_eq!(p2[0].resource.as_ref().unwrap().key, "proj-o-2"); + + let err = client + .transfer_project_to_org(u2, "proj-o-2", "org_123") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Cannot transfer to organization that user is not admin of" + ); + + let err = client + .transfer_project_to_org(u1, "proj-o-2", "org_123") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::NOT_FOUND), + "Cannot transfer a project that user does not own" + ); + + let err = client.delete_organization(u1, "org_123").await.unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::BAD_REQUEST), + "Cannot delete organization with projects in it" + ); + + let err = client + .transfer_project_from_org(u2, "proj-o-1", "org_123") + .await + .unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Cannot transfer from organization that user is not admin of" + ); + + client + .transfer_project_from_org(u1, "proj-o-1", "org_123") + .await + .unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let p1 = client.get_user_projects(u1).await.unwrap(); + + assert_eq!(p1.len(), 1); + assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1"); + + let err = client.delete_organization(u2, "org_123").await.unwrap_err(); + assert!( + matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN), + "Cannot delete organization that user does not own" + ); + + client.delete_organization(u1, "org_123").await.unwrap(); + tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await; + let o1 = client.get_organizations(u1).await.unwrap(); + + assert_eq!(o1, vec![]); + } } diff --git a/common-tests/src/permit_pdp.rs b/common-tests/src/permit_pdp.rs index 2dcf419e9..d5b6ca2ee 100644 --- a/common-tests/src/permit_pdp.rs +++ b/common-tests/src/permit_pdp.rs @@ -15,7 +15,8 @@ impl DockerInstance { let container_name = format!("shuttle_test_permit_{}", name); let e1 = format!("PDP_CONTROL_PLANE={api_url}"); let e2 = format!("PDP_API_KEY={api_key}"); - let env = [e1.as_str(), e2.as_str()]; + let e3 = "PDP_OPA_CLIENT_QUERY_TIMEOUT=10"; + let env = [e1.as_str(), e2.as_str(), e3]; let port = "7000"; let image = "docker.io/permitio/pdp-v2:0.2.37"; let is_ready_cmd = vec![ diff --git a/common/src/models/error.rs b/common/src/models/error.rs index 7cc3eb98e..de07c28da 100644 --- a/common/src/models/error.rs +++ b/common/src/models/error.rs @@ -98,6 +98,8 @@ pub enum ErrorKind { DeleteProjectFailed, #[error("Our server is at capacity and cannot serve your request at this time. Please try again in a few minutes.")] CapacityLimit, + #[error("{0:?}")] + InvalidOrganizationName(InvalidOrganizationName), } impl From for ApiError { @@ -130,6 +132,7 @@ impl From for ApiError { ErrorKind::NotReady => StatusCode::INTERNAL_SERVER_ERROR, ErrorKind::DeleteProjectFailed => StatusCode::INTERNAL_SERVER_ERROR, ErrorKind::CapacityLimit => StatusCode::SERVICE_UNAVAILABLE, + ErrorKind::InvalidOrganizationName(_) => StatusCode::BAD_REQUEST, }; Self { message: kind.to_string(), @@ -190,3 +193,7 @@ impl From for ApiError { 6. not be a reserved word." )] pub struct InvalidProjectName; + +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +#[error("Invalid organization name. Must not be more than 30 characters long.")] +pub struct InvalidOrganizationName; diff --git a/common/src/models/mod.rs b/common/src/models/mod.rs index 1d687f638..05a881250 100644 --- a/common/src/models/mod.rs +++ b/common/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod admin; pub mod deployment; pub mod error; +pub mod organization; pub mod project; pub mod resource; pub mod service; diff --git a/common/src/models/organization.rs b/common/src/models/organization.rs new file mode 100644 index 000000000..2ade0510f --- /dev/null +++ b/common/src/models/organization.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Serialize}; + +/// Minimal organization information +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Response { + /// Organization ID + pub id: String, + + /// Name used for display purposes + pub display_name: String, + + /// Is this user an admin of the organization + pub is_admin: bool, +} diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index 10ea0278b..4d8fd002d 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -21,14 +21,15 @@ use serde::{Deserialize, Serialize}; use shuttle_backends::auth::{AuthPublicKey, JwtAuthenticationLayer, ScopedLayer}; use shuttle_backends::axum::CustomErrorPath; use shuttle_backends::cache::CacheManager; +use shuttle_backends::client::permit::Organization; use shuttle_backends::metrics::{Metrics, TraceLayer}; use shuttle_backends::project_name::ProjectName; use shuttle_backends::request_span; use shuttle_backends::ClaimExt; use shuttle_common::claims::{Claim, Scope, EXP_MINUTES}; -use shuttle_common::models::error::ErrorKind; -use shuttle_common::models::service; +use shuttle_common::models::error::{ErrorKind, InvalidOrganizationName}; use shuttle_common::models::{admin::ProjectResponse, project, stats}; +use shuttle_common::models::{organization, service}; use shuttle_common::{deployment, VersionInfo}; use shuttle_proto::provisioner::provisioner_client::ProvisionerClient; use shuttle_proto::provisioner::Ping; @@ -36,7 +37,7 @@ use tokio::sync::mpsc::Sender; use tokio::sync::{Mutex, MutexGuard}; use tower::ServiceBuilder; use tower_http::cors::CorsLayer; -use tracing::{error, field, instrument, trace}; +use tracing::{error, field, instrument, trace, Span}; use ttl_cache::TtlCache; use ulid::Ulid; use uuid::Uuid; @@ -475,6 +476,113 @@ async fn route_project( .await } +#[instrument(skip_all)] +async fn get_organizations( + State(RouterState { service, .. }): State, + Claim { sub, .. }: Claim, +) -> Result>, Error> { + let orgs = service.permit_client.get_organizations(&sub).await?; + + Ok(AxumJson(orgs)) +} + +#[instrument(skip_all, fields(shuttle.organization.name = %organization_name, shuttle.organization.id = field::Empty))] +async fn create_organization( + State(RouterState { service, .. }): State, + CustomErrorPath(organization_name): CustomErrorPath, + Claim { sub, .. }: Claim, +) -> Result { + if organization_name.chars().count() > 30 { + return Err(Error::from_kind(ErrorKind::InvalidOrganizationName( + InvalidOrganizationName, + ))); + } + + let org = Organization { + id: format!("org_{}", Ulid::new()), + display_name: organization_name.clone(), + }; + + service + .permit_client + .create_organization(&sub, &org) + .await?; + + Span::current().record("shuttle.organization.id", &org.id); + + Ok("Organization created".to_string()) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id))] +async fn get_organization_projects( + State(RouterState { service, .. }): State, + CustomErrorPath(organization_id): CustomErrorPath, + Claim { sub, .. }: Claim, +) -> Result>, Error> { + let project_ids = service + .permit_client + .get_organization_projects(&sub, &organization_id) + .await?; + + let mut projects = Vec::with_capacity(project_ids.len()); + + for project_id in project_ids { + let project = service.find_project_by_id(&project_id).await?; + let idle_minutes = project.state.idle_minutes(); + + projects.push(project::Response { + id: project.id, + name: project.name, + state: project.state.into(), + idle_minutes, + }); + } + + Ok(AxumJson(projects)) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id))] +async fn delete_organization( + State(RouterState { service, .. }): State, + CustomErrorPath(organization_id): CustomErrorPath, + Claim { sub, .. }: Claim, +) -> Result { + service + .permit_client + .delete_organization(&sub, &organization_id) + .await?; + + Ok("Organization deleted".to_string()) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id, shuttle.project.id = %project_id))] +async fn transfer_project_to_organization( + State(RouterState { service, .. }): State, + CustomErrorPath((organization_id, project_id)): CustomErrorPath<(String, String)>, + Claim { sub, .. }: Claim, +) -> Result { + service + .permit_client + .transfer_project_to_org(&sub, &project_id, &organization_id) + .await?; + + Ok("Project transfered".to_string()) +} + +#[instrument(skip_all, fields(shuttle.organization.id = %organization_id, shuttle.project.id = %project_id))] +async fn transfer_project_from_organization( + State(RouterState { service, .. }): State, + CustomErrorPath((organization_id, project_id)): CustomErrorPath<(String, String)>, + Claim { sub, .. }: Claim, +) -> Result { + service + .permit_client + .transfer_project_from_org(&sub, &project_id, &organization_id) + .await?; + + Ok("Project transfered".to_string()) +} + async fn get_status( State(RouterState { sender, service, .. @@ -932,10 +1040,21 @@ impl ApiBuilder { .route("/projects/:project_name/*any", any(route_project)) .route_layer(middleware::from_fn(project_name_tracing_layer)); + let organization_routes = Router::new() + .route("/", get(get_organizations)) + .route("/:organization_name", post(create_organization)) + .route("/:organization_id", delete(delete_organization)) + .route("/:organization_id/projects", get(get_organization_projects)) + .route( + "/:organization_id/projects/:project_id", + post(transfer_project_to_organization).delete(transfer_project_from_organization), + ); + self.router = self .router .route("/", get(get_status)) .merge(project_routes) + .nest("/organizations", organization_routes) .route( "/versions", get(|| async { diff --git a/gateway/src/lib.rs b/gateway/src/lib.rs index d8412c912..cbdd4cc7f 100644 --- a/gateway/src/lib.rs +++ b/gateway/src/lib.rs @@ -17,6 +17,7 @@ use hyper::client::HttpConnector; use hyper::Client; use once_cell::sync::Lazy; use service::ContainerSettings; +use shuttle_backends::client::permit; use shuttle_backends::project_name::ProjectName; use shuttle_common::models::error::{ApiError, ErrorKind}; use shuttle_common::models::user::UserId; @@ -110,6 +111,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: permit::Error) -> Self { + Self::source(ErrorKind::Internal, error) + } +} + impl IntoResponse for Error { fn into_response(self) -> Response { let error: ApiError = self.kind.clone().into(); diff --git a/gateway/src/service.rs b/gateway/src/service.rs index ae7aa1587..1fc546d4b 100644 --- a/gateway/src/service.rs +++ b/gateway/src/service.rs @@ -678,8 +678,7 @@ impl GatewayService { self.permit_client .create_project(user_id, &project_id.to_string()) - .await - .map_err(|_| Error::from(ErrorKind::Internal))?; + .await?; transaction.commit().await?; @@ -711,10 +710,7 @@ impl GatewayService { .execute(&mut *transaction) .await?; - self.permit_client - .delete_project(&project_id) - .await - .map_err(|_| Error::from(ErrorKind::Internal))?; + self.permit_client.delete_project(&project_id).await?; transaction.commit().await?;