Skip to content

Commit 4c80f01

Browse files
chesedojonaro00
andauthored
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 <[email protected]> --------- Co-authored-by: jonaro00 <[email protected]>
1 parent 178e77c commit 4c80f01

File tree

10 files changed

+706
-133
lines changed

10 files changed

+706
-133
lines changed

backends/src/client/permit.rs

Lines changed: 334 additions & 120 deletions
Large diffs are not rendered by default.

backends/src/test_utils/gateway.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ use async_trait::async_trait;
44
use permit_client_rs::models::UserRead;
55
use permit_pdp_client_rs::models::UserPermissionsResult;
66
use serde::Serialize;
7+
use shuttle_common::models::organization;
78
use tokio::sync::Mutex;
89
use wiremock::{
910
http,
1011
matchers::{method, path, path_regex},
1112
Mock, MockServer, Request, ResponseTemplate,
1213
};
1314

14-
use crate::client::{permit::Error, PermissionsDal};
15+
use crate::client::{
16+
permit::{Error, Organization},
17+
PermissionsDal,
18+
};
1519

1620
pub async fn get_mocked_gateway_server() -> MockServer {
1721
let mock_server = MockServer::start().await;
@@ -159,4 +163,64 @@ impl PermissionsDal for PermissionsMock {
159163
.push(format!("allowed {user_id} {project_id} {action}"));
160164
Ok(true)
161165
}
166+
167+
async fn create_organization(&self, user_id: &str, org: &Organization) -> Result<(), Error> {
168+
self.calls.lock().await.push(format!(
169+
"create_organization {user_id} {} {}",
170+
org.id, org.display_name
171+
));
172+
Ok(())
173+
}
174+
175+
async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<(), Error> {
176+
self.calls
177+
.lock()
178+
.await
179+
.push(format!("delete_organization {user_id} {org_id}"));
180+
Ok(())
181+
}
182+
183+
async fn get_organization_projects(
184+
&self,
185+
user_id: &str,
186+
org_id: &str,
187+
) -> Result<Vec<String>, Error> {
188+
self.calls
189+
.lock()
190+
.await
191+
.push(format!("get_organization_projects {user_id} {org_id}"));
192+
Ok(Default::default())
193+
}
194+
195+
async fn get_organizations(&self, user_id: &str) -> Result<Vec<organization::Response>, Error> {
196+
self.calls
197+
.lock()
198+
.await
199+
.push(format!("get_organizations {user_id}"));
200+
Ok(Default::default())
201+
}
202+
203+
async fn transfer_project_to_org(
204+
&self,
205+
user_id: &str,
206+
project_id: &str,
207+
org_id: &str,
208+
) -> Result<(), Error> {
209+
self.calls.lock().await.push(format!(
210+
"transfer_project_to_org {user_id} {project_id} {org_id}"
211+
));
212+
Ok(())
213+
}
214+
215+
async fn transfer_project_from_org(
216+
&self,
217+
user_id: &str,
218+
project_id: &str,
219+
org_id: &str,
220+
) -> Result<(), Error> {
221+
self.calls.lock().await.push(format!(
222+
"transfer_project_from_org {user_id} {project_id} {org_id}"
223+
));
224+
Ok(())
225+
}
162226
}

backends/tests/integration/permit_tests.rs

Lines changed: 152 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ mod needs_docker {
88
};
99
use serial_test::serial;
1010
use shuttle_backends::client::{
11-
permit::{Client, Error, ResponseContent},
11+
permit::{Client, Error, Organization, ResponseContent},
1212
PermissionsDal,
1313
};
14-
use shuttle_common::claims::AccountTier;
14+
use shuttle_common::{claims::AccountTier, models::organization};
1515
use shuttle_common_tests::permit_pdp::DockerInstance;
1616
use test_context::{test_context, AsyncTestContext};
1717
use uuid::Uuid;
@@ -199,4 +199,154 @@ mod needs_docker {
199199

200200
assert!(p2.is_empty());
201201
}
202+
203+
#[test_context(Wrap)]
204+
#[tokio::test]
205+
#[serial]
206+
async fn test_organizations(Wrap(client): &mut Wrap) {
207+
let u1 = "user-o-1";
208+
let u2 = "user-o-2";
209+
client.new_user(u1).await.unwrap();
210+
client.new_user(u2).await.unwrap();
211+
212+
const SLEEP: u64 = 500;
213+
214+
tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await;
215+
216+
let org = Organization {
217+
id: "org_123".to_string(),
218+
display_name: "Test organization".to_string(),
219+
};
220+
221+
let err = client.create_organization(u1, &org).await.unwrap_err();
222+
assert!(
223+
matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN),
224+
"Only Pro users can create organizations"
225+
);
226+
227+
client.make_pro(u1).await.unwrap();
228+
tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await;
229+
230+
client.create_organization(u1, &org).await.unwrap();
231+
tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await;
232+
let o1 = client.get_organizations(u1).await.unwrap();
233+
234+
assert_eq!(
235+
o1,
236+
vec![organization::Response {
237+
id: "org_123".to_string(),
238+
display_name: "Test organization".to_string(),
239+
is_admin: true,
240+
}]
241+
);
242+
243+
let err = client
244+
.create_organization(
245+
u1,
246+
&Organization {
247+
id: "org_987".to_string(),
248+
display_name: "Second organization".to_string(),
249+
},
250+
)
251+
.await
252+
.unwrap_err();
253+
assert!(
254+
matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::BAD_REQUEST),
255+
"User cannot create more than one organization"
256+
);
257+
258+
client.create_project(u1, "proj-o-1").await.unwrap();
259+
tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await;
260+
let p1 = client.get_user_projects(u1).await.unwrap();
261+
262+
assert_eq!(p1.len(), 1);
263+
assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1");
264+
265+
client
266+
.transfer_project_to_org(u1, "proj-o-1", "org_123")
267+
.await
268+
.unwrap();
269+
tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await;
270+
let p1 = client.get_user_projects(u1).await.unwrap();
271+
272+
assert_eq!(p1.len(), 1);
273+
assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1");
274+
275+
let err = client
276+
.get_organization_projects(u2, "org_123")
277+
.await
278+
.unwrap_err();
279+
assert!(
280+
matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN),
281+
"User cannot view projects on an organization it does not belong to"
282+
);
283+
284+
let ps = client
285+
.get_organization_projects(u1, "org_123")
286+
.await
287+
.unwrap();
288+
assert_eq!(ps, vec!["proj-o-1"]);
289+
290+
client.create_project(u2, "proj-o-2").await.unwrap();
291+
tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await;
292+
let p2 = client.get_user_projects(u2).await.unwrap();
293+
294+
assert_eq!(p2.len(), 1);
295+
assert_eq!(p2[0].resource.as_ref().unwrap().key, "proj-o-2");
296+
297+
let err = client
298+
.transfer_project_to_org(u2, "proj-o-2", "org_123")
299+
.await
300+
.unwrap_err();
301+
assert!(
302+
matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN),
303+
"Cannot transfer to organization that user is not admin of"
304+
);
305+
306+
let err = client
307+
.transfer_project_to_org(u1, "proj-o-2", "org_123")
308+
.await
309+
.unwrap_err();
310+
assert!(
311+
matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::NOT_FOUND),
312+
"Cannot transfer a project that user does not own"
313+
);
314+
315+
let err = client.delete_organization(u1, "org_123").await.unwrap_err();
316+
assert!(
317+
matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::BAD_REQUEST),
318+
"Cannot delete organization with projects in it"
319+
);
320+
321+
let err = client
322+
.transfer_project_from_org(u2, "proj-o-1", "org_123")
323+
.await
324+
.unwrap_err();
325+
assert!(
326+
matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN),
327+
"Cannot transfer from organization that user is not admin of"
328+
);
329+
330+
client
331+
.transfer_project_from_org(u1, "proj-o-1", "org_123")
332+
.await
333+
.unwrap();
334+
tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await;
335+
let p1 = client.get_user_projects(u1).await.unwrap();
336+
337+
assert_eq!(p1.len(), 1);
338+
assert_eq!(p1[0].resource.as_ref().unwrap().key, "proj-o-1");
339+
340+
let err = client.delete_organization(u2, "org_123").await.unwrap_err();
341+
assert!(
342+
matches!(err, Error::ResponseError(ResponseContent { status, .. }) if status == StatusCode::FORBIDDEN),
343+
"Cannot delete organization that user does not own"
344+
);
345+
346+
client.delete_organization(u1, "org_123").await.unwrap();
347+
tokio::time::sleep(tokio::time::Duration::from_millis(SLEEP)).await;
348+
let o1 = client.get_organizations(u1).await.unwrap();
349+
350+
assert_eq!(o1, vec![]);
351+
}
202352
}

common-tests/src/permit_pdp.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ impl DockerInstance {
1515
let container_name = format!("shuttle_test_permit_{}", name);
1616
let e1 = format!("PDP_CONTROL_PLANE={api_url}");
1717
let e2 = format!("PDP_API_KEY={api_key}");
18-
let env = [e1.as_str(), e2.as_str()];
18+
let e3 = "PDP_OPA_CLIENT_QUERY_TIMEOUT=10";
19+
let env = [e1.as_str(), e2.as_str(), e3];
1920
let port = "7000";
2021
let image = "docker.io/permitio/pdp-v2:0.2.37";
2122
let is_ready_cmd = vec![

common/src/models/error.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ pub enum ErrorKind {
9898
DeleteProjectFailed,
9999
#[error("Our server is at capacity and cannot serve your request at this time. Please try again in a few minutes.")]
100100
CapacityLimit,
101+
#[error("{0:?}")]
102+
InvalidOrganizationName(InvalidOrganizationName),
101103
}
102104

103105
impl From<ErrorKind> for ApiError {
@@ -130,6 +132,7 @@ impl From<ErrorKind> for ApiError {
130132
ErrorKind::NotReady => StatusCode::INTERNAL_SERVER_ERROR,
131133
ErrorKind::DeleteProjectFailed => StatusCode::INTERNAL_SERVER_ERROR,
132134
ErrorKind::CapacityLimit => StatusCode::SERVICE_UNAVAILABLE,
135+
ErrorKind::InvalidOrganizationName(_) => StatusCode::BAD_REQUEST,
133136
};
134137
Self {
135138
message: kind.to_string(),
@@ -190,3 +193,7 @@ impl From<StatusCode> for ApiError {
190193
6. not be a reserved word."
191194
)]
192195
pub struct InvalidProjectName;
196+
197+
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
198+
#[error("Invalid organization name. Must not be more than 30 characters long.")]
199+
pub struct InvalidOrganizationName;

common/src/models/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod admin;
22
pub mod deployment;
33
pub mod error;
4+
pub mod organization;
45
pub mod project;
56
pub mod resource;
67
pub mod service;

common/src/models/organization.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
/// Minimal organization information
4+
#[derive(Debug, Default, Serialize, Deserialize, PartialEq)]
5+
pub struct Response {
6+
/// Organization ID
7+
pub id: String,
8+
9+
/// Name used for display purposes
10+
pub display_name: String,
11+
12+
/// Is this user an admin of the organization
13+
pub is_admin: bool,
14+
}

0 commit comments

Comments
 (0)