@@ -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 (
0 commit comments