Skip to content

Commit 9575972

Browse files
authored
[codex] enforce invitation role hierarchy (#673)
* enforce invitation role hierarchy * use repository error mapper for invitation role lookup
1 parent 5d27499 commit 9575972

2 files changed

Lines changed: 150 additions & 27 deletions

File tree

crates/services/src/organization/mod.rs

Lines changed: 142 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -834,6 +834,36 @@ impl OrganizationServiceImpl {
834834
}
835835
}
836836

837+
async fn get_invitation_requester_role(
838+
&self,
839+
organization_id: &OrganizationId,
840+
requester_id: &UserId,
841+
org: &Organization,
842+
) -> Result<MemberRole, OrganizationError> {
843+
if &org.owner_id == requester_id {
844+
return Ok(MemberRole::Owner);
845+
}
846+
847+
let member = self
848+
.repository
849+
.get_member(organization_id.0, requester_id.0)
850+
.await
851+
.map_err(Self::map_repository_error)?
852+
.ok_or_else(|| {
853+
OrganizationError::Unauthorized(
854+
"User is not a member of this organization".to_string(),
855+
)
856+
})?;
857+
858+
if !member.role.can_manage_members() {
859+
return Err(OrganizationError::Unauthorized(
860+
"Only owners and admins can invite members".to_string(),
861+
));
862+
}
863+
864+
Ok(member.role)
865+
}
866+
837867
/// Create invitations for users (supports unregistered users, private helper)
838868
async fn create_invitations_impl(
839869
&self,
@@ -842,25 +872,10 @@ impl OrganizationServiceImpl {
842872
invitations: Vec<(String, MemberRole)>, // (email, role) pairs
843873
expires_in_hours: i64,
844874
) -> Result<BatchInvitationResponse, OrganizationError> {
845-
// Check if requester has permission
846875
let org = self.get_organization_impl(organization_id.clone()).await?;
847-
if org.owner_id != requester_id {
848-
if let Ok(Some(member)) = self
849-
.repository
850-
.get_member(organization_id.0, requester_id.0)
851-
.await
852-
{
853-
if !member.role.can_manage_members() {
854-
return Err(OrganizationError::Unauthorized(
855-
"Only owners and admins can invite members".to_string(),
856-
));
857-
}
858-
} else {
859-
return Err(OrganizationError::Unauthorized(
860-
"User is not a member of this organization".to_string(),
861-
));
862-
}
863-
}
876+
let requester_role = self
877+
.get_invitation_requester_role(&organization_id, &requester_id, &org)
878+
.await?;
864879

865880
let mut results = Vec::new();
866881
let mut successful = 0;
@@ -872,6 +887,21 @@ impl OrganizationServiceImpl {
872887
};
873888

874889
for (email, role) in invitations {
890+
if !requester_role.can_invite_as(&role) {
891+
failed += 1;
892+
results.push(ports::InvitationResult {
893+
email,
894+
success: false,
895+
member: None,
896+
error: Some(format!(
897+
"Insufficient permissions to invite members as {role}"
898+
)),
899+
email_sent: false,
900+
email_error: None,
901+
});
902+
continue;
903+
}
904+
875905
// Check if user is already a member
876906
if let Ok(Some(user)) = self.user_repository.get_by_email(&email).await {
877907
if let Ok(Some(_)) = self
@@ -1543,6 +1573,7 @@ mod tests {
15431573

15441574
struct StubOrgRepo {
15451575
org: Organization,
1576+
member: Option<OrganizationMember>,
15461577
}
15471578

15481579
#[async_trait]
@@ -1565,10 +1596,16 @@ mod tests {
15651596

15661597
async fn get_member(
15671598
&self,
1568-
_: Uuid,
1569-
_: Uuid,
1599+
organization_id: Uuid,
1600+
user_id: Uuid,
15701601
) -> Result<Option<OrganizationMember>, RepositoryError> {
1571-
Ok(None)
1602+
Ok(self
1603+
.member
1604+
.as_ref()
1605+
.filter(|member| {
1606+
member.organization_id.0 == organization_id && member.user_id.0 == user_id
1607+
})
1608+
.cloned())
15721609
}
15731610

15741611
async fn update(
@@ -1886,11 +1923,29 @@ mod tests {
18861923
Arc<StubInvitationRepo>,
18871924
Arc<StubEmailSender>,
18881925
Arc<StubUserRepo>,
1926+
) {
1927+
make_service_with_requester_role(outcome, invitations_url, MemberRole::Owner)
1928+
}
1929+
1930+
fn make_service_with_requester_role(
1931+
outcome: Result<EmailDeliveryOutcome, EmailError>,
1932+
invitations_url: Option<String>,
1933+
requester_role: MemberRole,
1934+
) -> (
1935+
OrganizationServiceImpl,
1936+
Arc<StubInvitationRepo>,
1937+
Arc<StubEmailSender>,
1938+
Arc<StubUserRepo>,
18891939
) {
18901940
let owner_id = UserId(Uuid::new_v4());
1941+
let requester_id = if requester_role == MemberRole::Owner {
1942+
owner_id.clone()
1943+
} else {
1944+
UserId(Uuid::new_v4())
1945+
};
18911946
let org_id = OrganizationId(Uuid::new_v4());
18921947
let org = Organization {
1893-
id: org_id,
1948+
id: org_id.clone(),
18941949
name: "Example Org".to_string(),
18951950
description: None,
18961951
owner_id: owner_id.clone(),
@@ -1899,11 +1954,21 @@ mod tests {
18991954
created_at: chrono::Utc::now(),
19001955
updated_at: chrono::Utc::now(),
19011956
};
1957+
let member = if requester_role == MemberRole::Owner {
1958+
None
1959+
} else {
1960+
Some(OrganizationMember {
1961+
organization_id: org_id,
1962+
user_id: requester_id.clone(),
1963+
role: requester_role.clone(),
1964+
joined_at: chrono::Utc::now(),
1965+
})
1966+
};
19021967
let inviter = User {
1903-
id: owner_id,
1904-
email: "owner@example.com".to_string(),
1905-
username: "owner".to_string(),
1906-
display_name: Some("Owner".to_string()),
1968+
id: requester_id,
1969+
email: format!("{requester_role}@example.com"),
1970+
username: requester_role.to_string(),
1971+
display_name: Some(requester_role.to_string()),
19071972
avatar_url: None,
19081973
auth_provider: "test".to_string(),
19091974
role: UserRole::User,
@@ -1925,7 +1990,7 @@ mod tests {
19251990
get_by_id_calls: Mutex::new(0),
19261991
});
19271992
let service = OrganizationServiceImpl::new_with_email_sender(
1928-
Arc::new(StubOrgRepo { org }) as Arc<dyn OrganizationRepository>,
1993+
Arc::new(StubOrgRepo { org, member }) as Arc<dyn OrganizationRepository>,
19291994
user_repo.clone() as Arc<dyn UserRepository>,
19301995
invitation_repo.clone() as Arc<dyn OrganizationInvitationRepository>,
19311996
email_sender.clone() as Arc<dyn EmailSender>,
@@ -2009,6 +2074,56 @@ mod tests {
20092074
assert_eq!(*user_repo.get_by_id_calls.lock().unwrap(), 1);
20102075
}
20112076

2077+
#[tokio::test]
2078+
async fn create_invitations_rejects_roles_above_requester_role() {
2079+
let (service, invitation_repo, email_sender, user_repo) = make_service_with_requester_role(
2080+
Ok(EmailDeliveryOutcome::Sent {
2081+
message_id: Some("resend-email-id".to_string()),
2082+
}),
2083+
None,
2084+
MemberRole::Admin,
2085+
);
2086+
let org = service
2087+
.repository
2088+
.get_by_id(Uuid::nil())
2089+
.await
2090+
.unwrap()
2091+
.unwrap();
2092+
2093+
let response = service
2094+
.create_invitations(
2095+
org.id,
2096+
user_repo.inviter.id.clone(),
2097+
vec![
2098+
("owner@example.com".to_string(), MemberRole::Owner),
2099+
("admin@example.com".to_string(), MemberRole::Admin),
2100+
("member@example.com".to_string(), MemberRole::Member),
2101+
],
2102+
168,
2103+
)
2104+
.await
2105+
.unwrap();
2106+
2107+
assert_eq!(response.total, 3);
2108+
assert_eq!(response.successful, 2);
2109+
assert_eq!(response.failed, 1);
2110+
assert_eq!(response.results[0].email, "owner@example.com");
2111+
assert!(!response.results[0].success);
2112+
assert_eq!(
2113+
response.results[0].error.as_deref(),
2114+
Some("Insufficient permissions to invite members as owner")
2115+
);
2116+
assert!(response.results[1].success);
2117+
assert!(response.results[2].success);
2118+
assert!(email_sender.sent_to.lock().unwrap().is_empty());
2119+
2120+
let records = invitation_repo.records.lock().unwrap();
2121+
assert_eq!(records.len(), 2);
2122+
assert!(records.iter().all(|invitation| {
2123+
invitation.role == MemberRole::Admin || invitation.role == MemberRole::Member
2124+
}));
2125+
}
2126+
20122127
#[tokio::test]
20132128
async fn create_invitations_keeps_invite_when_email_fails() {
20142129
let (service, invitation_repo, _, _) = make_service(

crates/services/src/organization/ports.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ impl MemberRole {
6464
matches!(self, MemberRole::Owner | MemberRole::Admin)
6565
}
6666

67+
pub fn can_invite_as(&self, role: &MemberRole) -> bool {
68+
match self {
69+
MemberRole::Owner => true,
70+
MemberRole::Admin => matches!(role, MemberRole::Admin | MemberRole::Member),
71+
MemberRole::Member => false,
72+
}
73+
}
74+
6775
pub fn can_manage_api_keys(&self) -> bool {
6876
// All members can create and manage their own API keys
6977
true

0 commit comments

Comments
 (0)