From 178e77cede54ec58b9e50a37e16e68d2f2a185ff Mon Sep 17 00:00:00 2001 From: Pieter Date: Fri, 5 Apr 2024 10:37:26 +0100 Subject: [PATCH 1/3] misc: nix shell (#1726) --- shell.nix | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shell.nix b/shell.nix index 8297f8f3b..ac00663b8 100644 --- a/shell.nix +++ b/shell.nix @@ -1,7 +1,7 @@ let moz_overlay = import (builtins.fetchTarball https://github.com/mozilla/nixpkgs-mozilla/archive/master.tar.gz); # Pin to stable from https://status.nixos.org/ - nixpkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/596a8e828c5dfa504f91918d0fa4152db3ab5502.tar.gz") { overlays = [ moz_overlay ]; }; + nixpkgs = import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/219951b495fc2eac67b1456824cc1ec1fd2ee659.tar.gz") { overlays = [ moz_overlay ]; }; in with nixpkgs; stdenv.mkDerivation { @@ -11,11 +11,10 @@ in openssl ]; buildInputs = with nixpkgs; [ - ((rustChannelOf{ channel = "1.75.0"; }).rust.override { + ((rustChannelOf{ channel = "1.77.1"; }).rust.override { extensions = ["rust-src"]; }) cargo-watch - terraform awscli2 websocat protobuf From 4c80f011fabf83c93f2bcbb1d68f92c5846e334f Mon Sep 17 00:00:00 2001 From: Pieter Date: Fri, 5 Apr 2024 10:44:03 +0100 Subject: [PATCH 2/3] feat: orgs (#1720) * feat: simple org management * feat: project to / from org transferal * feat: add org routes to gw * refactor: touch ups * refactor: remove PoC commented implementations * refactor: clippy suggestions * refactor: more formating issues * refactor: add validation * Apply suggestions from code review Co-authored-by: jonaro00 <54029719+jonaro00@users.noreply.github.com> --------- Co-authored-by: jonaro00 <54029719+jonaro00@users.noreply.github.com> --- backends/src/client/permit.rs | 454 +++++++++++++++------ backends/src/test_utils/gateway.rs | 66 ++- backends/tests/integration/permit_tests.rs | 154 ++++++- common-tests/src/permit_pdp.rs | 3 +- common/src/models/error.rs | 7 + common/src/models/mod.rs | 1 + common/src/models/organization.rs | 14 + gateway/src/api/latest.rs | 125 +++++- gateway/src/lib.rs | 7 + gateway/src/service.rs | 8 +- 10 files changed, 706 insertions(+), 133 deletions(-) create mode 100644 common/src/models/organization.rs 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?; From d26e6c838ef478b513401e0c9fae77cfb2677840 Mon Sep 17 00:00:00 2001 From: jonaro00 <54029719+jonaro00@users.noreply.github.com> Date: Fri, 5 Apr 2024 14:29:27 +0200 Subject: [PATCH 3/3] feat(admin): change project owner (#1725) * wip: change project owner * feat(admin): change project owner * fmt * fmt * feat: transfer project to user * fix: route name, query * fix: admin error on non 2xx * fmt --- Cargo.lock | 1 + admin/Cargo.toml | 1 + admin/src/args.rs | 6 ++ admin/src/client.rs | 29 +++++-- admin/src/config.rs | 4 + admin/src/main.rs | 10 +++ backends/src/client/permit.rs | 121 ++++++++++++++++------------- backends/src/test_utils/gateway.rs | 49 +++++++----- gateway/src/api/latest.rs | 17 +++- gateway/src/service.rs | 29 ++++++- 10 files changed, 183 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae4f01ee0..27ead5f6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5167,6 +5167,7 @@ name = "shuttle-admin" version = "0.43.0" dependencies = [ "anyhow", + "bytes", "clap", "dirs", "reqwest", diff --git a/admin/Cargo.toml b/admin/Cargo.toml index 7b9bed567..861e93f1a 100644 --- a/admin/Cargo.toml +++ b/admin/Cargo.toml @@ -8,6 +8,7 @@ publish = false shuttle-common = { workspace = true, features = ["models"] } anyhow = { workspace = true } +bytes = { workspace = true } clap = { workspace = true, features = ["env"] } dirs = { workspace = true } reqwest = { workspace = true, features = ["json"] } diff --git a/admin/src/args.rs b/admin/src/args.rs index 2e35d9e49..e6e170b38 100644 --- a/admin/src/args.rs +++ b/admin/src/args.rs @@ -1,6 +1,7 @@ use std::{fs, io, path::PathBuf}; use clap::{Error, Parser, Subcommand}; +use shuttle_common::models::user::UserId; #[derive(Parser, Debug)] pub struct Args { @@ -27,6 +28,11 @@ pub enum Command { /// Manage project names ProjectNames, + ChangeProjectOwner { + project_name: String, + new_user_id: UserId, + }, + /// Viewing and managing stats #[command(subcommand)] Stats(StatsCommand), diff --git a/admin/src/client.rs b/admin/src/client.rs index ddc6f595f..deb743cd9 100644 --- a/admin/src/client.rs +++ b/admin/src/client.rs @@ -1,4 +1,5 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use bytes::Bytes; use serde::{de::DeserializeOwned, Serialize}; use shuttle_common::models::{admin::ProjectResponse, stats, ToJson}; use tracing::trace; @@ -73,6 +74,15 @@ impl Client { self.get("/admin/projects").await } + pub async fn change_project_owner(&self, project_name: &str, new_user_id: &str) -> Result<()> { + self.get_raw(&format!( + "/admin/projects/change-owner/{project_name}/{new_user_id}" + )) + .await?; + + Ok(()) + } + pub async fn get_load(&self) -> Result { self.get("/admin/stats/load").await } @@ -130,15 +140,20 @@ impl Client { .context("failed to extract json body from delete response") } - async fn get(&self, path: &str) -> Result { - reqwest::Client::new() + async fn get_raw(&self, path: &str) -> Result { + let res = reqwest::Client::new() .get(format!("{}{}", self.api_url, path)) .bearer_auth(&self.api_key) .send() .await - .context("failed to make get request")? - .to_json() - .await - .context("failed to post text body from response") + .context("making request")?; + if !res.status().is_success() { + bail!("API call returned non-2xx: {:?}", res); + } + res.bytes().await.context("getting response body") + } + + async fn get(&self, path: &str) -> Result { + serde_json::from_slice(&self.get_raw(path).await?).context("deserializing body") } } diff --git a/admin/src/config.rs b/admin/src/config.rs index 5b63f955a..df7b6ecd3 100644 --- a/admin/src/config.rs +++ b/admin/src/config.rs @@ -1,6 +1,10 @@ use std::{fs, path::PathBuf}; pub fn get_api_key() -> String { + if let Ok(s) = std::env::var("SHUTTLE_API_KEY") { + return s; + } + let data = fs::read_to_string(config_path()).expect("shuttle config file to exist"); let toml: toml::Value = toml::from_str(&data).expect("to parse shuttle config file"); diff --git a/admin/src/main.rs b/admin/src/main.rs index 21c5a338a..d4ecfc9db 100644 --- a/admin/src/main.rs +++ b/admin/src/main.rs @@ -165,6 +165,16 @@ async fn main() { client.idle_cch().await.expect("cch projects to be idled"); "Idled CCH projects".to_string() } + Command::ChangeProjectOwner { + project_name, + new_user_id, + } => { + client + .change_project_owner(&project_name, &new_user_id) + .await + .unwrap(); + format!("Changed project owner: {project_name} -> {new_user_id}") + } }; println!("{res}"); diff --git a/backends/src/client/permit.rs b/backends/src/client/permit.rs index f3c84cbbc..e4cb7eb66 100644 --- a/backends/src/client/permit.rs +++ b/backends/src/client/permit.rs @@ -36,48 +36,52 @@ pub trait PermissionsDal { // User management /// Get a user with the given ID - async fn get_user(&self, user_id: &str) -> Result; + async fn get_user(&self, user_id: &str) -> Result; /// Delete a user with the given ID - async fn delete_user(&self, user_id: &str) -> Result<(), Error>; + async fn delete_user(&self, user_id: &str) -> Result<()>; /// Create a new user and set their tier correctly - async fn new_user(&self, user_id: &str) -> Result; + async fn new_user(&self, user_id: &str) -> Result; /// Set a user to be a Pro user - async fn make_pro(&self, user_id: &str) -> Result<(), Error>; + async fn make_pro(&self, user_id: &str) -> Result<()>; /// Set a user to be a Basic user - async fn make_basic(&self, user_id: &str) -> Result<(), Error>; + async fn make_basic(&self, user_id: &str) -> Result<()>; // Project management /// Creates a Project resource and assigns the user as admin for that project - async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error>; + async fn create_project(&self, user_id: &str, project_id: &str) -> Result<()>; /// Deletes a Project resource - async fn delete_project(&self, project_id: &str) -> Result<(), Error>; + async fn delete_project(&self, project_id: &str) -> Result<()>; // Organization management /// Creates an Organization resource and assigns the user as admin for the organization - async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error>; + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<()>; /// Deletes an Organization resource - async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error>; + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<()>; /// Get a list of all the organizations a user has access to - async fn get_organizations(&self, user_id: &str) -> Result, Error>; + async fn get_organizations(&self, user_id: &str) -> Result>; /// Get a list of all project IDs that belong to an organization - async fn get_organization_projects( + async fn get_organization_projects(&self, user_id: &str, org_id: &str) -> Result>; + + /// Transfers a project from a user to another user + async fn transfer_project_to_user( &self, user_id: &str, - org_id: &str, - ) -> Result, Error>; + project_id: &str, + new_user_id: &str, + ) -> Result<()>; - /// Transfers a project from a users to an organization + /// Transfers a project from a user to an organization async fn transfer_project_to_org( &self, user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error>; + ) -> Result<()>; /// Transfers a project from an organization to a user async fn transfer_project_from_org( @@ -85,14 +89,14 @@ pub trait PermissionsDal { user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error>; + ) -> Result<()>; // Permissions queries /// Get list of all projects user has permissions for - async fn get_user_projects(&self, user_id: &str) -> Result, Error>; + async fn get_user_projects(&self, user_id: &str) -> Result>; /// Check if user can perform action on this project - async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result; + async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result; } /// Simple details of an organization to create @@ -163,22 +167,22 @@ impl Client { #[async_trait] impl PermissionsDal for Client { - async fn get_user(&self, user_id: &str) -> Result { + async fn get_user(&self, user_id: &str) -> Result { Ok(get_user(&self.api, &self.proj_id, &self.env_id, user_id).await?) } - async fn delete_user(&self, user_id: &str) -> Result<(), Error> { + async fn delete_user(&self, user_id: &str) -> Result<()> { Ok(delete_user(&self.api, &self.proj_id, &self.env_id, user_id).await?) } - async fn new_user(&self, user_id: &str) -> Result { + async fn new_user(&self, user_id: &str) -> Result { let user = self.create_user(user_id).await?; self.make_basic(&user.id.to_string()).await?; self.get_user(&user.id.to_string()).await } - async fn make_pro(&self, user_id: &str) -> Result<(), Error> { + async fn make_pro(&self, user_id: &str) -> Result<()> { let user = self.get_user(user_id).await?; if user.roles.is_some_and(|roles| { @@ -192,7 +196,7 @@ impl PermissionsDal for Client { self.assign_role(user_id, &AccountTier::Pro).await } - async fn make_basic(&self, user_id: &str) -> Result<(), Error> { + async fn make_basic(&self, user_id: &str) -> Result<()> { let user = self.get_user(user_id).await?; if user @@ -205,7 +209,7 @@ impl PermissionsDal for Client { self.assign_role(user_id, &AccountTier::Basic).await } - async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> { + async fn create_project(&self, user_id: &str, project_id: &str) -> Result<()> { if let Err(e) = create_resource_instance( &self.api, &self.proj_id, @@ -236,7 +240,7 @@ impl PermissionsDal for Client { Ok(()) } - async fn delete_project(&self, project_id: &str) -> Result<(), Error> { + async fn delete_project(&self, project_id: &str) -> Result<()> { Ok(delete_resource_instance( &self.api, &self.proj_id, @@ -246,7 +250,7 @@ impl PermissionsDal for Client { .await?) } - async fn get_user_projects(&self, user_id: &str) -> Result, Error> { + async fn get_user_projects(&self, user_id: &str) -> Result> { let perms = get_user_permissions_user_permissions_post( &self.pdp, UserPermissionsQuery { @@ -266,7 +270,7 @@ impl PermissionsDal for Client { Ok(perms.into_values().collect()) } - async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result { + async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result { // NOTE: This API function was modified in upstream to use AuthorizationQuery let res = is_allowed_allowed_post( &self.pdp, @@ -292,7 +296,7 @@ impl PermissionsDal for Client { Ok(res.allow.unwrap_or_default()) } - async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error> { + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<()> { if !self.allowed_org(user_id, &org.id, "create").await? { return Err(Error::ResponseError(ResponseContent { status: StatusCode::FORBIDDEN, @@ -341,7 +345,7 @@ impl PermissionsDal for Client { Ok(()) } - async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error> { + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<()> { if !self.allowed_org(user_id, org_id, "manage").await? { return Err(Error::ResponseError(ResponseContent { status: StatusCode::FORBIDDEN, @@ -369,11 +373,7 @@ impl PermissionsDal for Client { .await?) } - async fn get_organization_projects( - &self, - user_id: &str, - org_id: &str, - ) -> Result, Error> { + async fn get_organization_projects(&self, user_id: &str, org_id: &str) -> Result> { if !self.allowed_org(user_id, org_id, "view").await? { return Err(Error::ResponseError(ResponseContent { status: StatusCode::FORBIDDEN, @@ -407,7 +407,7 @@ impl PermissionsDal for Client { Ok(projects) } - async fn get_organizations(&self, user_id: &str) -> Result, Error> { + async fn get_organizations(&self, user_id: &str) -> Result> { let perms = get_user_permissions_user_permissions_post( &self.pdp, UserPermissionsQuery { @@ -446,12 +446,27 @@ impl PermissionsDal for Client { Ok(res) } + async fn transfer_project_to_user( + &self, + user_id: &str, + project_id: &str, + new_user_id: &str, + ) -> Result<()> { + self.unassign_resource_role(user_id, format!("Project:{project_id}"), "admin") + .await?; + + self.assign_resource_role(new_user_id, format!("Project:{project_id}"), "admin") + .await?; + + Ok(()) + } + async fn transfer_project_to_org( &self, user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error> { + ) -> Result<()> { if !self.allowed_org(user_id, org_id, "manage").await? { return Err(Error::ResponseError(ResponseContent { status: StatusCode::FORBIDDEN, @@ -478,7 +493,7 @@ impl PermissionsDal for Client { user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error> { + ) -> Result<()> { if !self.allowed_org(user_id, org_id, "manage").await? { return Err(Error::ResponseError(ResponseContent { status: StatusCode::FORBIDDEN, @@ -503,7 +518,7 @@ impl PermissionsDal for Client { // Helpers for trait methods impl Client { - // pub async fn get_organization_members(&self, org_name: &str) -> Result, Error> { + // pub async fn get_organization_members(&self, org_name: &str) -> Result> { // self.api // .get( // &format!( @@ -519,7 +534,7 @@ impl Client { // &self, // org_name: &str, // user_id: &str, - // ) -> Result<(), Error> { + // ) -> Result<()> { // self.api // .post( // &format!("{}/role_assignments", self.facts), @@ -538,7 +553,7 @@ impl Client { // &self, // org_name: &str, // user_id: &str, - // ) -> Result<(), Error> { + // ) -> Result<()> { // self.api // .delete( // &format!("{}/role_assignments", self.facts), @@ -553,7 +568,7 @@ impl Client { // .await // } - async fn create_user(&self, user_id: &str) -> Result { + async fn create_user(&self, user_id: &str) -> Result { Ok(create_user( &self.api, &self.proj_id, @@ -566,7 +581,7 @@ impl Client { .await?) } - async fn assign_role(&self, user_id: &str, role: &AccountTier) -> Result<(), Error> { + async fn assign_role(&self, user_id: &str, role: &AccountTier) -> Result<()> { assign_role( &self.api, &self.proj_id, @@ -583,7 +598,7 @@ impl Client { Ok(()) } - async fn unassign_role(&self, user_id: &str, role: &AccountTier) -> Result<(), Error> { + async fn unassign_role(&self, user_id: &str, role: &AccountTier) -> Result<()> { unassign_role( &self.api, &self.proj_id, @@ -605,7 +620,7 @@ impl Client { user_id: &str, resource_instance: String, role: &str, - ) -> Result<(), Error> { + ) -> Result<()> { assign_role( &self.api, &self.proj_id, @@ -627,7 +642,7 @@ impl Client { user_id: &str, resource_instance: String, role: &str, - ) -> Result<(), Error> { + ) -> Result<()> { unassign_role( &self.api, &self.proj_id, @@ -644,7 +659,7 @@ impl Client { Ok(()) } - async fn allowed_org(&self, user_id: &str, org_id: &str, action: &str) -> Result { + async fn allowed_org(&self, user_id: &str, org_id: &str, action: &str) -> Result { // NOTE: This API function was modified in upstream to use AuthorizationQuery let res = is_allowed_allowed_post( &self.pdp, @@ -670,12 +685,7 @@ impl Client { Ok(res.allow.unwrap_or_default()) } - async fn assign_relationship( - &self, - subject: String, - role: &str, - object: String, - ) -> Result<(), Error> { + async fn assign_relationship(&self, subject: String, role: &str, object: String) -> Result<()> { create_relationship_tuple( &self.api, &self.proj_id, @@ -697,7 +707,7 @@ impl Client { subject: String, role: &str, object: String, - ) -> Result<(), Error> { + ) -> Result<()> { delete_relationship_tuple( &self.api, &self.proj_id, @@ -713,7 +723,7 @@ impl Client { Ok(()) } - pub async fn sync_pdp(&self) -> Result<(), Error> { + pub async fn sync_pdp(&self) -> Result<()> { trigger_policy_update_policy_updater_trigger_post(&self.pdp).await?; trigger_policy_data_update_data_updater_trigger_post(&self.pdp).await?; @@ -736,7 +746,7 @@ mod admin { impl Client { /// Copy and overwrite a permit env's policies to another env. /// Requires a project level API key. - pub async fn copy_environment(&self, target_env: &str) -> Result<(), Error> { + pub async fn copy_environment(&self, target_env: &str) -> Result<()> { copy_environment( &self.api, &self.proj_id, @@ -786,6 +796,7 @@ pub enum Error { #[error("response error: {0}")] ResponseError(ResponseContent), } +pub type Result = std::result::Result; #[derive(Debug)] pub struct ResponseContent { pub status: reqwest::StatusCode, diff --git a/backends/src/test_utils/gateway.rs b/backends/src/test_utils/gateway.rs index 192ba916f..b4b18a08d 100644 --- a/backends/src/test_utils/gateway.rs +++ b/backends/src/test_utils/gateway.rs @@ -13,7 +13,7 @@ use wiremock::{ }; use crate::client::{ - permit::{Error, Organization}, + permit::{Organization, Result}, PermissionsDal, }; @@ -101,12 +101,12 @@ pub struct PermissionsMock { #[async_trait] impl PermissionsDal for PermissionsMock { - async fn get_user(&self, user_id: &str) -> Result { + async fn get_user(&self, user_id: &str) -> Result { self.calls.lock().await.push(format!("get_user {user_id}")); Ok(Default::default()) } - async fn delete_user(&self, user_id: &str) -> Result<(), Error> { + async fn delete_user(&self, user_id: &str) -> Result<()> { self.calls .lock() .await @@ -114,17 +114,17 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn new_user(&self, user_id: &str) -> Result { + async fn new_user(&self, user_id: &str) -> Result { self.calls.lock().await.push(format!("new_user {user_id}")); Ok(Default::default()) } - async fn make_pro(&self, user_id: &str) -> Result<(), Error> { + async fn make_pro(&self, user_id: &str) -> Result<()> { self.calls.lock().await.push(format!("make_pro {user_id}")); Ok(()) } - async fn make_basic(&self, user_id: &str) -> Result<(), Error> { + async fn make_basic(&self, user_id: &str) -> Result<()> { self.calls .lock() .await @@ -132,7 +132,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn create_project(&self, user_id: &str, project_id: &str) -> Result<(), Error> { + async fn create_project(&self, user_id: &str, project_id: &str) -> Result<()> { self.calls .lock() .await @@ -140,7 +140,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn delete_project(&self, project_id: &str) -> Result<(), Error> { + async fn delete_project(&self, project_id: &str) -> Result<()> { self.calls .lock() .await @@ -148,7 +148,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn get_user_projects(&self, user_id: &str) -> Result, Error> { + async fn get_user_projects(&self, user_id: &str) -> Result> { self.calls .lock() .await @@ -156,7 +156,7 @@ impl PermissionsDal for PermissionsMock { Ok(vec![]) } - async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result { + async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result { self.calls .lock() .await @@ -164,7 +164,7 @@ impl PermissionsDal for PermissionsMock { Ok(true) } - async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error> { + async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<()> { self.calls.lock().await.push(format!( "create_organization {user_id} {} {}", org.id, org.display_name @@ -172,7 +172,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error> { + async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<()> { self.calls .lock() .await @@ -180,11 +180,7 @@ impl PermissionsDal for PermissionsMock { Ok(()) } - async fn get_organization_projects( - &self, - user_id: &str, - org_id: &str, - ) -> Result, Error> { + async fn get_organization_projects(&self, user_id: &str, org_id: &str) -> Result> { self.calls .lock() .await @@ -192,7 +188,7 @@ impl PermissionsDal for PermissionsMock { Ok(Default::default()) } - async fn get_organizations(&self, user_id: &str) -> Result, Error> { + async fn get_organizations(&self, user_id: &str) -> Result> { self.calls .lock() .await @@ -200,12 +196,25 @@ impl PermissionsDal for PermissionsMock { Ok(Default::default()) } + async fn transfer_project_to_user( + &self, + user_id: &str, + project_id: &str, + new_user_id: &str, + ) -> Result<()> { + self.calls.lock().await.push(format!( + "transfer_project_to_user {user_id} {project_id} {new_user_id}" + )); + + Ok(()) + } + async fn transfer_project_to_org( &self, user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error> { + ) -> Result<()> { self.calls.lock().await.push(format!( "transfer_project_to_org {user_id} {project_id} {org_id}" )); @@ -217,7 +226,7 @@ impl PermissionsDal for PermissionsMock { user_id: &str, project_id: &str, org_id: &str, - ) -> Result<(), Error> { + ) -> Result<()> { self.calls.lock().await.push(format!( "transfer_project_from_org {user_id} {project_id} {org_id}" )); diff --git a/gateway/src/api/latest.rs b/gateway/src/api/latest.rs index 4d8fd002d..84733519c 100644 --- a/gateway/src/api/latest.rs +++ b/gateway/src/api/latest.rs @@ -916,6 +916,17 @@ async fn get_projects( Ok(AxumJson(projects)) } +async fn change_project_owner( + State(RouterState { service, .. }): State, + Path((project_name, new_user_id)): Path<(String, String)>, +) -> Result<(), Error> { + service + .update_project_owner(&project_name, &new_user_id) + .await?; + + Ok(()) +} + #[derive(Clone)] pub(crate) struct RouterState { pub service: Arc, @@ -1010,6 +1021,10 @@ impl ApiBuilder { pub fn with_default_routes(mut self) -> Self { let admin_routes = Router::new() .route("/projects", get(get_projects)) + .route( + "/projects/change-owner/:project_name/:new_user_id", + get(change_project_owner), + ) .route("/revive", post(revive_projects)) .route("/destroy", post(destroy_projects)) .route("/idle-cch", post(idle_cch_projects)) @@ -1042,7 +1057,7 @@ impl ApiBuilder { let organization_routes = Router::new() .route("/", get(get_organizations)) - .route("/:organization_name", post(create_organization)) + .route("/name/:organization_name", post(create_organization)) .route("/:organization_id", delete(delete_organization)) .route("/:organization_id/projects", get(get_organization_projects)) .route( diff --git a/gateway/src/service.rs b/gateway/src/service.rs index 1fc546d4b..5fa50752f 100644 --- a/gateway/src/service.rs +++ b/gateway/src/service.rs @@ -32,7 +32,7 @@ use sqlx::error::DatabaseError; use sqlx::migrate::Migrator; use sqlx::sqlite::SqlitePool; use sqlx::types::Json as SqlxJson; -use sqlx::{query, Error as SqlxError, QueryBuilder, Row}; +use sqlx::{query, query_as, Error as SqlxError, QueryBuilder, Row}; use tokio::sync::mpsc::Sender; use tokio::time::timeout; use tonic::codegen::tokio_stream::StreamExt; @@ -492,6 +492,33 @@ impl GatewayService { Ok(()) } + pub async fn update_project_owner( + &self, + project_name: &str, + new_user_id: &str, + ) -> Result<(), Error> { + let mut tr = self.db.begin().await?; + let (project_id, user_id) = query_as::<_, (String, String)>( + "SELECT project_id, user_id FROM projects WHERE project_name = ?1", + ) + .bind(project_name) + .fetch_one(&mut *tr) + .await?; + query("UPDATE projects SET user_id = ?1 WHERE project_name = ?2") + .bind(new_user_id) + .bind(project_name) + .execute(&mut *tr) + .await?; + + self.permit_client + .transfer_project_to_user(&user_id, &project_id, new_user_id) + .await?; + + tr.commit().await?; + + Ok(()) + } + pub async fn user_id_from_project(&self, project_name: &ProjectName) -> Result { query("SELECT user_id FROM projects WHERE project_name = ?1") .bind(project_name)