Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: orgs #1720

Merged
merged 10 commits into from
Apr 5, 2024
454 changes: 334 additions & 120 deletions backends/src/client/permit.rs

Large diffs are not rendered by default.

66 changes: 65 additions & 1 deletion backends/src/test_utils/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ 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,
matchers::{method, path, path_regex},
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;
Expand Down Expand Up @@ -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<Vec<String>, 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<Vec<organization::Response>, 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(())
}
}
154 changes: 152 additions & 2 deletions backends/tests/integration/permit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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![]);
}
}
3 changes: 2 additions & 1 deletion common-tests/src/permit_pdp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down
7 changes: 7 additions & 0 deletions common/src/models/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ErrorKind> for ApiError {
Expand Down Expand Up @@ -130,6 +132,7 @@ impl From<ErrorKind> 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(),
Expand Down Expand Up @@ -190,3 +193,7 @@ impl From<StatusCode> for ApiError {
6. not be a reserved word."
)]
pub struct InvalidProjectName;

#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[error("Invalid organization name. Organization names must less than 30 characters.")]
chesedo marked this conversation as resolved.
Show resolved Hide resolved
pub struct InvalidOrganizationName;
1 change: 1 addition & 0 deletions common/src/models/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
14 changes: 14 additions & 0 deletions common/src/models/organization.rs
Original file line number Diff line number Diff line change
@@ -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,
}
Loading