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: get project owners and only personal projects #1733

Merged
merged 7 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions backends/src/client/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,17 @@ mod tests {
id: "00000000000000000000000001".to_string(),
name: "user-1-project-1".to_string(),
state: State::Stopped,
idle_minutes: Some(30)
idle_minutes: Some(30),
owner: shuttle_common::models::project::Owner::User("user-1".to_string()),
is_admin: true,
},
Response {
id: "00000000000000000000000002".to_string(),
name: "user-1-project-2".to_string(),
state: State::Ready,
idle_minutes: Some(30)
idle_minutes: Some(30),
owner: shuttle_common::models::project::Owner::User("user-1".to_string()),
is_admin: true,
}
]
)
Expand Down
146 changes: 128 additions & 18 deletions backends/src/client/permit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ use permit_pdp_client_rs::{
policy_updater_api::trigger_policy_update_policy_updater_trigger_post,
Error as PermitPDPClientError,
},
models::{AuthorizationQuery, Resource, User, UserPermissionsQuery, UserPermissionsResult},
models::{AuthorizationQuery, Resource, User, UserPermissionsQuery},
};
use serde::{Deserialize, Serialize};
use shuttle_common::{
claims::AccountTier,
models::{error::ApiError, organization},
models::{error::ApiError, organization, project},
};
use tracing::error;

Expand All @@ -60,6 +60,9 @@ pub trait PermissionsDal {
/// Deletes a Project resource
async fn delete_project(&self, project_id: &str) -> Result<()>;

/// Get list of all projects the user has direct permissions for
async fn get_personal_projects(&self, user_id: &str) -> Result<Vec<String>>;

// Organization management

/// Creates an Organization resource and assigns the user as admin for the organization
Expand All @@ -68,6 +71,10 @@ pub trait PermissionsDal {
/// Deletes an Organization resource
async fn delete_organization(&self, user_id: &str, org_id: &str) -> Result<()>;

/// Get the details of an organization
async fn get_organization(&self, user_id: &str, org_id: &str)
-> Result<organization::Response>;

/// Get a list of all the organizations a user has access to
async fn get_organizations(&self, user_id: &str) -> Result<Vec<organization::Response>>;

Expand Down Expand Up @@ -123,10 +130,11 @@ pub trait PermissionsDal {

// Permissions queries

/// Get list of all projects user has permissions for
async fn get_user_projects(&self, user_id: &str) -> Result<Vec<UserPermissionsResult>>;
/// Check if user can perform action on this project
async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result<bool>;

/// Get the owner of a project
async fn get_project_owner(&self, user_id: &str, project_id: &str) -> Result<Owner>;
}

/// Simple details of an organization to create
Expand All @@ -153,6 +161,21 @@ impl OrganizationAttributes {
}
}

#[derive(Debug, PartialEq)]
pub enum Owner {
User(String),
Organization(String),
}

impl From<Owner> for project::Owner {
fn from(owner: Owner) -> Self {
match owner {
Owner::User(id) => project::Owner::User(id),
Owner::Organization(id) => project::Owner::Organization(id),
}
}
}

/// Wrapper for the Permit.io API and PDP (Policy decision point) API
#[derive(Clone)]
pub struct Client {
Expand Down Expand Up @@ -280,24 +303,31 @@ impl PermissionsDal for Client {
.await?)
}

async fn get_user_projects(&self, user_id: &str) -> Result<Vec<UserPermissionsResult>> {
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!["Project".to_owned()]),
tenants: Some(vec!["default".to_owned()]),
..Default::default()
},
async fn get_personal_projects(&self, user_id: &str) -> Result<Vec<String>> {
let projects = list_role_assignments(
&self.api,
&self.proj_id,
&self.env_id,
Some(user_id),
Some("admin"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if they are not admin? Is this now a "get projects where I'm admin" method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite. This is a "get projects where I have the admin role assigned method". So it won't return projects where the user is an "admin" because they are the admin of the org. I need this to be able to show users the projects they own which is not in some org.

Some("default"),
Some("Project"),
None,
None,
None,
None,
)
.await?;
.await?
.into_iter()
.map(|ra| ra.resource_instance.expect("to have resource instance"))
.map(|ri| {
ri.strip_prefix("Project:")
.expect("ID to start with the 'Project:' prefix")
.to_string()
})
.collect();

Ok(perms.into_values().collect())
Ok(projects)
}

async fn allowed(&self, user_id: &str, project_id: &str, action: &str) -> Result<bool> {
Expand Down Expand Up @@ -436,6 +466,52 @@ impl PermissionsDal for Client {
Ok(projects)
}

async fn get_organization(
&self,
user_id: &str,
org_id: &str,
) -> Result<organization::Response> {
let mut perms = get_user_permissions_user_permissions_post(
&self.pdp,
UserPermissionsQuery {
user: Box::new(User {
key: user_id.to_owned(),
..Default::default()
}),
resources: Some(vec![format!("Organization:{org_id}")]),
tenants: Some(vec!["default".to_owned()]),
..Default::default()
},
None,
None,
)
.await?;

let Some(org) = perms.remove(&format!("Organization:{org_id}")) else {
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 res = if let Some(resource) = org.resource {
let attributes = resource.attributes.unwrap_or_default();
let org_attrs = serde_json::from_value::<OrganizationAttributes>(attributes)
.expect("to read organization attributes");

organization::Response {
id: resource.key,
display_name: org_attrs.display_name,
is_admin: org.roles.unwrap_or_default().contains(&"admin".to_string()),
}
} else {
unreachable!("the permission will always have a resource")
};

Ok(res)
}

async fn get_organizations(&self, user_id: &str) -> Result<Vec<organization::Response>> {
let perms = get_user_permissions_user_permissions_post(
&self.pdp,
Expand Down Expand Up @@ -644,6 +720,40 @@ impl PermissionsDal for Client {

Ok(members)
}

async fn get_project_owner(&self, user_id: &str, project_id: &str) -> Result<Owner> {
if !self.allowed(user_id, project_id, "view").await? {
return Err(Error::ResponseError(ResponseContent {
status: StatusCode::FORBIDDEN,
content: "User does not have permission to view the project".to_owned(),
entity: "Project".to_owned(),
}));
}

let relationships = list_relationship_tuples(
&self.api,
&self.proj_id,
&self.env_id,
Some(true),
None,
None,
Some("default"),
None,
Some("parent"),
Some(&format!("Project:{project_id}")),
None,
Some("Organization"),
)
.await?;

if let Some(rel) = relationships.into_iter().next() {
let org_id = rel.subject_details.expect("to have subject details").key;
Ok(Owner::Organization(org_id))
} else {
// If a user is able to view a project while the project has no parent org, then this user must be the project owner
Ok(Owner::User(user_id.to_owned()))
}
}
}

// Helpers for trait methods
Expand Down
27 changes: 23 additions & 4 deletions backends/src/test_utils/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ use std::sync::Arc;

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;
Expand All @@ -13,7 +12,7 @@ use wiremock::{
};

use crate::client::{
permit::{Organization, Result},
permit::{Organization, Owner, Result},
PermissionsDal,
};

Expand Down Expand Up @@ -148,11 +147,11 @@ impl PermissionsDal for PermissionsMock {
Ok(())
}

async fn get_user_projects(&self, user_id: &str) -> Result<Vec<UserPermissionsResult>> {
async fn get_personal_projects(&self, user_id: &str) -> Result<Vec<String>> {
self.calls
.lock()
.await
.push(format!("get_user_projects {user_id}"));
.push(format!("get_personal_projects {user_id}"));
Ok(vec![])
}

Expand Down Expand Up @@ -180,6 +179,18 @@ impl PermissionsDal for PermissionsMock {
Ok(())
}

async fn get_organization(
&self,
user_id: &str,
org_id: &str,
) -> Result<organization::Response> {
self.calls
.lock()
.await
.push(format!("get_organization {user_id} {org_id}"));
Ok(Default::default())
}

async fn get_organization_projects(&self, user_id: &str, org_id: &str) -> Result<Vec<String>> {
self.calls
.lock()
Expand Down Expand Up @@ -268,4 +279,12 @@ impl PermissionsDal for PermissionsMock {
.push(format!("get_organization_members {user_id} {org_id}"));
Ok(Default::default())
}

async fn get_project_owner(&self, user_id: &str, project_id: &str) -> Result<Owner> {
self.calls
.lock()
.await
.push(format!("get_project_owner {user_id} {project_id}"));
Ok(Owner::User(user_id.to_string()))
}
}
Loading