diff --git a/changelog.d/2-features/WPB-18190 b/changelog.d/2-features/WPB-18190 new file mode 100644 index 0000000000..98265f532d --- /dev/null +++ b/changelog.d/2-features/WPB-18190 @@ -0,0 +1 @@ +Allow collaborator to be removed from a team. diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index 11f09b8052..b7ef7bc465 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -1183,3 +1183,9 @@ refreshAppCookie :: (MakesValue u) => u -> String -> String -> App Response refreshAppCookie u tid appId = do req <- baseRequest u Brig Versioned $ joinHttpPath ["teams", tid, "apps", appId, "cookies"] submit "POST" req + +removeTeamCollaborator :: (MakesValue owner, MakesValue collaborator, HasCallStack) => owner -> String -> collaborator -> App Response +removeTeamCollaborator owner tid collaborator = do + (_, collabId) <- objQid collaborator + req <- baseRequest owner Galley Versioned $ joinHttpPath ["teams", tid, "collaborators", collabId] + submit "DELETE" req diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index 62d845cfe4..3f0b697245 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -106,47 +106,47 @@ awaitNotification user lastNotifId selector = do since0 <- mapM objId lastNotifId head <$> awaitNotifications user (Nothing :: Maybe ()) since0 1 selector -isDeleteUserNotif :: (MakesValue a) => a -> App Bool +isDeleteUserNotif :: (HasCallStack, MakesValue a) => a -> App Bool isDeleteUserNotif n = nPayload n %. "type" `isEqual` "user.delete" -isFeatureConfigUpdateNotif :: (MakesValue a) => a -> App Bool +isFeatureConfigUpdateNotif :: (HasCallStack, MakesValue a) => a -> App Bool isFeatureConfigUpdateNotif n = nPayload n %. "type" `isEqual` "feature-config.update" -isNewMessageNotif :: (MakesValue a) => a -> App Bool +isNewMessageNotif :: (HasCallStack, MakesValue a) => a -> App Bool isNewMessageNotif n = fieldEquals n "payload.0.type" "conversation.otr-message-add" -isNewMLSMessageNotif :: (MakesValue a) => a -> App Bool +isNewMLSMessageNotif :: (HasCallStack, MakesValue a) => a -> App Bool isNewMLSMessageNotif n = fieldEquals n "payload.0.type" "conversation.mls-message-add" -isWelcomeNotif :: (MakesValue a) => a -> App Bool +isWelcomeNotif :: (HasCallStack, MakesValue a) => a -> App Bool isWelcomeNotif n = fieldEquals n "payload.0.type" "conversation.mls-welcome" -isMemberJoinNotif :: (MakesValue a) => a -> App Bool +isMemberJoinNotif :: (HasCallStack, MakesValue a) => a -> App Bool isMemberJoinNotif n = fieldEquals n "payload.0.type" "conversation.member-join" -isConvLeaveNotif :: (MakesValue a) => a -> App Bool +isConvLeaveNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvLeaveNotif n = fieldEquals n "payload.0.type" "conversation.member-leave" -isConvLeaveNotifWithLeaver :: (MakesValue user, MakesValue a) => user -> a -> App Bool +isConvLeaveNotifWithLeaver :: (HasCallStack, MakesValue user, MakesValue a) => user -> a -> App Bool isConvLeaveNotifWithLeaver user n = fieldEquals n "payload.0.type" "conversation.member-leave" &&~ (n %. "payload.0.data.user_ids.0") `isEqual` (user %. "id") -isNotifConv :: (MakesValue conv, MakesValue a, HasCallStack) => conv -> a -> App Bool +isNotifConv :: (HasCallStack, MakesValue conv, MakesValue a, HasCallStack) => conv -> a -> App Bool isNotifConv conv n = fieldEquals n "payload.0.qualified_conversation" (objQidObject conv) -isNotifConvId :: (MakesValue a, HasCallStack) => ConvId -> a -> App Bool +isNotifConvId :: (HasCallStack, MakesValue a, HasCallStack) => ConvId -> a -> App Bool isNotifConvId conv n = do let subconvField = "payload.0.subconv" fieldEquals n "payload.0.qualified_conversation" (convIdToQidObject conv) &&~ maybe (isNothing <$> lookupField n subconvField) (fieldEquals n subconvField) conv.subconvId -isNotifForUser :: (MakesValue user, MakesValue a, HasCallStack) => user -> a -> App Bool +isNotifForUser :: (HasCallStack, MakesValue user, MakesValue a, HasCallStack) => user -> a -> App Bool isNotifForUser user n = fieldEquals n "payload.0.data.qualified_user_ids.0" (objQidObject user) -isNotifFromUser :: (MakesValue user, MakesValue a, HasCallStack) => user -> a -> App Bool +isNotifFromUser :: (HasCallStack, MakesValue user, MakesValue a, HasCallStack) => user -> a -> App Bool isNotifFromUser user n = fieldEquals n "payload.0.qualified_from" (objQidObject user) isConvNameChangeNotif :: (HasCallStack, MakesValue a) => a -> App Bool @@ -171,55 +171,58 @@ isConvAccessUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool isConvAccessUpdateNotif n = fieldEquals n "payload.0.type" "conversation.access-update" -isConvCreateNotif :: (MakesValue a) => a -> App Bool +isConvCreateNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvCreateNotif n = fieldEquals n "payload.0.type" "conversation.create" -- | like 'isConvCreateNotif' but excludes self conversations -isConvCreateNotifNotSelf :: (MakesValue a) => a -> App Bool +isConvCreateNotifNotSelf :: (HasCallStack, MakesValue a) => a -> App Bool isConvCreateNotifNotSelf n = fieldEquals n "payload.0.type" "conversation.create" &&~ do not <$> fieldEquals n "payload.0.data.access" ["private"] -isConvDeleteNotif :: (MakesValue a) => a -> App Bool +isConvDeleteNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvDeleteNotif n = fieldEquals n "payload.0.type" "conversation.delete" -notifTypeIsEqual :: (MakesValue a) => String -> a -> App Bool +notifTypeIsEqual :: (HasCallStack, MakesValue a) => String -> a -> App Bool notifTypeIsEqual typ n = nPayload n %. "type" `isEqual` typ -isTeamMemberJoinNotif :: (MakesValue a) => a -> App Bool +isTeamMemberJoinNotif :: (HasCallStack, MakesValue a) => a -> App Bool isTeamMemberJoinNotif = notifTypeIsEqual "team.member-join" -isTeamMemberLeaveNotif :: (MakesValue a) => a -> App Bool +isTeamMemberLeaveNotif :: (HasCallStack, MakesValue a) => a -> App Bool isTeamMemberLeaveNotif = notifTypeIsEqual "team.member-leave" -isTeamCollaboratorAddedNotif :: (MakesValue a) => a -> App Bool +isTeamCollaboratorAddedNotif :: (HasCallStack, MakesValue a) => a -> App Bool isTeamCollaboratorAddedNotif = notifTypeIsEqual "team.collaborator-add" -isUserActivateNotif :: (MakesValue a) => a -> App Bool +isTeamCollaboratorRemovedNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isTeamCollaboratorRemovedNotif = notifTypeIsEqual "team.collaborator-remove" + +isUserActivateNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserActivateNotif = notifTypeIsEqual "user.activate" -isUserClientAddNotif :: (MakesValue a) => a -> App Bool +isUserClientAddNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserClientAddNotif = notifTypeIsEqual "user.client-add" -isUserUpdatedNotif :: (MakesValue a) => a -> App Bool +isUserUpdatedNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserUpdatedNotif = notifTypeIsEqual "user.update" -isUserClientRemoveNotif :: (MakesValue a) => a -> App Bool +isUserClientRemoveNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserClientRemoveNotif = notifTypeIsEqual "user.client-remove" -isUserLegalholdRequestNotif :: (MakesValue a) => a -> App Bool +isUserLegalholdRequestNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserLegalholdRequestNotif = notifTypeIsEqual "user.legalhold-request" -isUserLegalholdEnabledNotif :: (MakesValue a) => a -> App Bool +isUserLegalholdEnabledNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserLegalholdEnabledNotif = notifTypeIsEqual "user.legalhold-enable" -isUserLegalholdDisabledNotif :: (MakesValue a) => a -> App Bool +isUserLegalholdDisabledNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserLegalholdDisabledNotif = notifTypeIsEqual "user.legalhold-disable" -isUserConnectionNotif :: (MakesValue a) => a -> App Bool +isUserConnectionNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserConnectionNotif = notifTypeIsEqual "user.connection" -isConnectionNotif :: (MakesValue a) => String -> a -> App Bool +isConnectionNotif :: (HasCallStack, MakesValue a) => String -> a -> App Bool isConnectionNotif status n = -- NB: -- (&&) <$> (print "hello" *> pure False) <*> fail "bla" === _|_ @@ -227,10 +230,10 @@ isConnectionNotif status n = nPayload n %. "type" `isEqual` "user.connection" &&~ nPayload n %. "connection.status" `isEqual` status -isUserGroupCreatedNotif :: (MakesValue a) => a -> App Bool +isUserGroupCreatedNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserGroupCreatedNotif = notifTypeIsEqual "user-group.created" -isUserGroupUpdatedNotif :: (MakesValue a) => a -> App Bool +isUserGroupUpdatedNotif :: (HasCallStack, MakesValue a) => a -> App Bool isUserGroupUpdatedNotif = notifTypeIsEqual "user-group.updated" isConvResetNotif :: (HasCallStack, MakesValue n) => n -> App Bool @@ -264,7 +267,7 @@ assertLeaveNotification fromUser conv user client leaver = ] ) -assertConvUserDeletedNotif :: (MakesValue leaverId) => WebSocket -> leaverId -> App () +assertConvUserDeletedNotif :: (HasCallStack, MakesValue leaverId) => WebSocket -> leaverId -> App () assertConvUserDeletedNotif ws leaverId = do n <- awaitMatch isConvLeaveNotif ws nPayload n %. "data.qualified_user_ids.0" `shouldMatch` leaverId diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index bcff88d58c..9e8050d177 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -2,8 +2,9 @@ module Test.TeamCollaborators where import API.Brig import API.Galley +import qualified API.GalleyInternal as Internal import Data.Tuple.Extra -import Notifications (isTeamCollaboratorAddedNotif) +import Notifications (isConvLeaveNotif, isTeamCollaboratorAddedNotif, isTeamCollaboratorRemovedNotif, isTeamMemberLeaveNotif) import SetupHelpers import Testlib.Prelude @@ -156,4 +157,117 @@ testImplicitConnectionNoCollaborator = do -- Alice and Bob aren't connected at all. postOne2OneConversation bob alice team0 "chit-chat" >>= assertLabel 403 "no-team-member" - postOne2OneConversation alice bob team0 "chat-chit" >>= assertLabel 403 "non-binding-team-members" +testRemoveCollaboratorInTeamsO2O :: (HasCallStack) => App () +testRemoveCollaboratorInTeamsO2O = do + (owner0, team0, [alice]) <- createTeam OwnDomain 2 + (owner1, team1, [bob]) <- createTeam OwnDomain 2 + + -- At the time of writing, it wasn't clear if this should be a bot instead. + charlie <- randomUser OwnDomain def + addTeamCollaborator owner0 team0 charlie ["implicit_connection"] >>= assertSuccess + addTeamCollaborator owner1 team1 charlie ["implicit_connection"] >>= assertSuccess + + convId <- + postOne2OneConversation charlie alice team0 "chit-chat" `bindResponse` \resp -> do + resp.status `shouldMatchInt` 201 + resp.json %. "qualified_id" + postOne2OneConversation charlie bob team1 "chit-chat" >>= assertSuccess + Internal.getConversation convId >>= assertSuccess + + removeTeamCollaborator owner0 team0 charlie >>= assertSuccess + + getMLSOne2OneConversation charlie alice >>= assertLabel 403 "not-connected" + postOne2OneConversation charlie alice team0 "chit-chat" >>= assertLabel 403 "no-team-member" + Internal.getConversation convId >>= assertLabel 404 "no-conversation" + getMLSOne2OneConversation charlie bob >>= assertSuccess + +testRemoveCollaboratorInO2OConnected :: (HasCallStack) => App () +testRemoveCollaboratorInO2OConnected = do + (owner0, team0, [alice]) <- createTeam OwnDomain 2 + + -- At the time of writing, it wasn't clear if this should be a bot instead. + bob <- randomUser OwnDomain def + connectTwoUsers alice bob + + addTeamCollaborator owner0 team0 bob ["implicit_connection"] >>= assertSuccess + + postOne2OneConversation bob alice team0 "chit-chat" >>= assertSuccess + + removeTeamCollaborator owner0 team0 bob >>= assertSuccess + + getMLSOne2OneConversation bob alice >>= assertSuccess + +testRemoveCollaboratorInO2O :: (HasCallStack) => App () +testRemoveCollaboratorInO2O = do + (owner0, team0, [alice]) <- createTeam OwnDomain 2 + + -- At the time of writing, it wasn't clear if this should be a bot instead. + bob <- randomUser OwnDomain def + addTeamCollaborator owner0 team0 bob ["implicit_connection"] >>= assertSuccess + + teamConvId <- + postOne2OneConversation bob alice team0 "chit-chat" `bindResponse` \resp -> do + resp.status `shouldMatchInt` 201 + resp.json %. "qualified_id" + Internal.getConversation teamConvId >>= assertSuccess + + connectTwoUsers alice bob + personalConvId <- postConversation alice defProteus {qualifiedUsers = [bob]} >>= getJSON 201 + Internal.getConversation personalConvId >>= assertSuccess + + removeTeamCollaborator owner0 team0 bob >>= assertSuccess + + postOne2OneConversation bob alice team0 "chit-chat" >>= assertLabel 403 "no-team-member" + Internal.getConversation teamConvId >>= assertLabel 404 "no-conversation" + + getMLSOne2OneConversation bob alice >>= assertSuccess + Internal.getConversation personalConvId >>= assertSuccess + +testRemoveCollaboratorInTeamConversation :: (HasCallStack) => App () +testRemoveCollaboratorInTeamConversation = do + (owner, team, [alice, bob]) <- createTeam OwnDomain 3 + + conv <- + postConversation + owner + defProteus {team = Just team, qualifiedUsers = [alice, bob]} + >>= getJSON 201 + + withWebSockets [owner, alice, bob] $ \[wsOwner, wsAlice, wsBob] -> do + removeTeamCollaborator owner team bob >>= assertSuccess + + bobId <- bob %. "qualified_id" + bobUnqualifiedId <- bobId %. "id" + let checkLeaveEvent :: (MakesValue a, HasCallStack) => a -> App () + checkLeaveEvent evt = do + evt %. "payload.0.data.user" `shouldMatch` bobUnqualifiedId + evt %. "payload.0.team" `shouldMatch` team + checkRemoveEvent :: (MakesValue a, HasCallStack) => a -> App () + checkRemoveEvent evt = do + evt %. "payload.0.data.user" `shouldMatch` bobUnqualifiedId + evt %. "payload.0.team" `shouldMatch` team + checkConvLeaveEvent :: (MakesValue a, HasCallStack) => a -> App () + checkConvLeaveEvent evt = do + evt %. "payload.0.data.qualified_user_ids" `shouldMatch` [bobId] + evt %. "payload.0.team" `shouldMatch` team + + awaitMatch isTeamMemberLeaveNotif wsOwner >>= checkLeaveEvent + awaitMatch isTeamMemberLeaveNotif wsAlice >>= checkRemoveEvent + awaitMatch isTeamMemberLeaveNotif wsBob >>= checkLeaveEvent + awaitMatch isTeamCollaboratorRemovedNotif wsOwner >>= checkRemoveEvent + awaitMatch isConvLeaveNotif wsAlice >>= checkConvLeaveEvent + + getConversation alice conv `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + otherMember <- assertOne =<< asList (resp.json %. "members.others") + otherMember %. "qualified_id" `shouldNotMatch` (bob %. "qualified_id") + + getConversation bob conv `bindResponse` \resp -> do + -- should be 404 + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "access-denied" + + Internal.getConversation conv `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + otherMembers <- asList (resp.json %. "members.others") + traverse (%. "qualified_id") otherMembers `shouldMatchSet` traverse (%. "qualified_id") [owner, alice] diff --git a/libs/wire-api/src/Wire/API/Event/Team.hs b/libs/wire-api/src/Wire/API/Event/Team.hs index 9c1612770c..70a52ea822 100644 --- a/libs/wire-api/src/Wire/API/Event/Team.hs +++ b/libs/wire-api/src/Wire/API/Event/Team.hs @@ -125,6 +125,7 @@ data EventType | ConvDelete | CollaboratorAdd | AppCreate + | CollaboratorRemove deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform EventType) deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType @@ -142,7 +143,8 @@ instance ToSchema EventType where element "team.conversation-create" ConvCreate, element "team.conversation-delete" ConvDelete, element "team.collaborator-add" CollaboratorAdd, - element "team.app-create" AppCreate + element "team.app-create" AppCreate, + element "team.collaborator-remove" CollaboratorRemove ] -------------------------------------------------------------------------------- @@ -159,6 +161,7 @@ data EventData | EdConvDelete ConvId | EdCollaboratorAdd UserId [CollaboratorPermission] | EdAppCreate UserId + | EdCollaboratorRemove UserId deriving stock (Eq, Show, Generic) -- FUTUREWORK: this is outright wrong; see "Wire.API.Event.Conversation" on how to do this properly. @@ -189,6 +192,7 @@ instance ToJSON EventData where "permissions" A..= perms ] toJSON (EdAppCreate usr) = A.object ["user" A..= usr] + toJSON (EdCollaboratorRemove usr) = A.object ["user" A..= usr] eventDataType :: EventData -> EventType eventDataType (EdTeamCreate _) = TeamCreate @@ -201,6 +205,7 @@ eventDataType (EdConvCreate _) = ConvCreate eventDataType (EdConvDelete _) = ConvDelete eventDataType (EdCollaboratorAdd _ _) = CollaboratorAdd eventDataType (EdAppCreate _) = AppCreate +eventDataType (EdCollaboratorRemove _) = CollaboratorRemove parseEventData :: EventType -> Maybe Value -> Parser EventData parseEventData MemberJoin Nothing = fail "missing event data for type 'team.member-join'" @@ -215,11 +220,11 @@ parseEventData MemberLeave Nothing = fail "missing event data for type 'team.mem parseEventData MemberLeave (Just j) = do let f o = EdMemberLeave <$> o .: "user" withObject "member leave data" f j -parseEventData ConvCreate Nothing = fail "missing event data for type 'team.conversation-create" +parseEventData ConvCreate Nothing = fail "missing event data for type 'team.conversation-create'" parseEventData ConvCreate (Just j) = do let f o = EdConvCreate <$> o .: "conv" withObject "conversation create data" f j -parseEventData ConvDelete Nothing = fail "missing event data for type 'team.conversation-delete" +parseEventData ConvDelete Nothing = fail "missing event data for type 'team.conversation-delete'" parseEventData ConvDelete (Just j) = do let f o = EdConvDelete <$> o .: "conv" withObject "conversation delete data" f j @@ -235,6 +240,10 @@ parseEventData AppCreate Nothing = fail "missing event data for type 'team.app-c parseEventData AppCreate (Just j) = do let f o = EdAppCreate <$> o .: "user" withObject "app create data" f j +parseEventData CollaboratorRemove Nothing = fail "missing event data for type 'team.collaborator-remove" +parseEventData CollaboratorRemove (Just j) = do + let f o = EdCollaboratorRemove <$> o .: "user" + withObject "collaborator remove data" f j parseEventData _ Nothing = pure EdTeamDelete parseEventData t (Just _) = fail $ "unexpected event data for type " <> show t @@ -250,5 +259,6 @@ genEventData = \case ConvDelete -> EdConvDelete <$> arbitrary CollaboratorAdd -> EdCollaboratorAdd <$> arbitrary <*> arbitrary AppCreate -> EdAppCreate <$> arbitrary + CollaboratorRemove -> EdCollaboratorRemove <$> arbitrary makeLenses ''Event diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs index ef66057baa..f3aee85819 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs @@ -207,6 +207,19 @@ type TeamMemberAPI = "CSV of team members" CSV ) + :<|> Named + "remove-team-collaborator" + ( Summary "Remove a collaborator from the team." + :> CanThrow OperationDenied + :> CanThrow 'NotATeamMember + :> From 'V12 + :> ZLocalUser + :> "teams" + :> Capture "tid" TeamId + :> "collaborators" + :> Capture "uid" UserId + :> MultiVerb1 'DELETE '[JSON] (RespondEmpty 200 "") + ) type TeamMemberDeleteResultResponseType = '[ RespondEmpty 202 "Team member scheduled for deletion", diff --git a/libs/wire-api/src/Wire/API/Team/Member.hs b/libs/wire-api/src/Wire/API/Team/Member.hs index 82651202af..f6a75b1ebc 100644 --- a/libs/wire-api/src/Wire/API/Team/Member.hs +++ b/libs/wire-api/src/Wire/API/Team/Member.hs @@ -482,6 +482,7 @@ data HiddenPerm | JoinRegularConversations | CreateApp | ManageApps + | RemoveTeamCollaborator deriving (Eq, Ord, Show) -- | See Note [hidden team roles] @@ -568,7 +569,8 @@ roleHiddenPermissions role = HiddenPermissions p p DownloadTeamMembersCsv, NewTeamCollaborator, CreateApp, - ManageApps + ManageApps, + RemoveTeamCollaborator ] roleHiddenPerms RoleMember = (roleHiddenPerms RoleExternalPartner <>) $ diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs index 63a429ba41..8e62014dc7 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Cassandra.hs @@ -17,6 +17,9 @@ module Wire.ConversationStore.Cassandra ( interpretConversationStoreToCassandra, + deleteConversation, + members, + removeMembersFromLocalConv, ) where diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs index 5d7e7962e7..55a0e4658d 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs @@ -13,5 +13,6 @@ data TeamCollaboratorsStore m a where GetTeamCollaborator :: TeamId -> UserId -> TeamCollaboratorsStore m (Maybe TeamCollaborator) GetTeamCollaborations :: UserId -> TeamCollaboratorsStore m ([TeamCollaborator]) GetTeamCollaboratorsWithIds :: Set TeamId -> Set UserId -> TeamCollaboratorsStore m [TeamCollaborator] + RemoveTeamCollaborator :: UserId -> TeamId -> TeamCollaboratorsStore m () makeSem ''TeamCollaboratorsStore diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs index af3114044e..cb9b3b4180 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs @@ -38,6 +38,7 @@ interpretTeamCollaboratorsStoreToPostgres = GetTeamCollaborator teamId userId -> getTeamCollaboratorImpl teamId userId GetTeamCollaborations userId -> getTeamCollaborationsImpl userId GetTeamCollaboratorsWithIds teamIds userIds -> getTeamCollaboratorsWithIdsImpl teamIds userIds + RemoveTeamCollaborator userId teamId -> removeTeamCollaboratorImpl userId teamId getTeamCollaboratorImpl :: ( Member (Input Pool) r, @@ -124,6 +125,30 @@ getAllTeamCollaboratorsImpl teamId = do select user_id :: uuid, team_id :: uuid, permissions :: int2[] from collaborators where team_id = ($1 :: uuid) |] +removeTeamCollaboratorImpl :: + ( Member (Input Pool) r, + Member (Embed IO) r, + Member (Error UsageError) r + ) => + UserId -> + TeamId -> + Sem r () +removeTeamCollaboratorImpl userId teamId = do + pool <- input + eitherErrorOrUnit <- liftIO $ use pool session + either throw pure eitherErrorOrUnit + where + session :: Session () + session = statement (userId, teamId) deleteStatement + + deleteStatement :: Statement (UserId, TeamId) () + deleteStatement = + lmap + (bimap toUUID toUUID) + $ [resultlessStatement| + delete from collaborators where user_id = ($1 :: uuid) and team_id = ($2 :: uuid) + |] + toTeamCollaborator :: (UUID, UUID, Vector Int16) -> TeamCollaborator toTeamCollaborator ((Id -> gUser), (Id -> gTeam), (toPermissions -> gPermissions)) = TeamCollaborator {..} diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs index d7e7df2075..2e57f9729f 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs @@ -14,5 +14,6 @@ data TeamCollaboratorsSubsystem m a where InternalGetTeamCollaborator :: TeamId -> UserId -> TeamCollaboratorsSubsystem m (Maybe TeamCollaborator) InternalGetTeamCollaborations :: UserId -> TeamCollaboratorsSubsystem m [TeamCollaborator] InternalGetTeamCollaboratorsWithIds :: Set TeamId -> Set UserId -> TeamCollaboratorsSubsystem m [TeamCollaborator] + InternalRemoveTeamCollaborator :: UserId -> TeamId -> TeamCollaboratorsSubsystem m () makeSem ''TeamCollaboratorsSubsystem diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs index b2a0c26a29..4430d129ad 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs @@ -34,6 +34,7 @@ interpretTeamCollaboratorsSubsystem = interpret $ \case InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId InternalGetTeamCollaboratorsWithIds teams userIds -> internalGetTeamCollaboratorsWithIdsImpl teams userIds + InternalRemoveTeamCollaborator user team -> internalRemoveTeamCollaboratorImpl user team internalGetTeamCollaboratorImpl :: (Member Store.TeamCollaboratorsStore r) => @@ -90,6 +91,15 @@ internalGetTeamCollaboratorsWithIdsImpl :: internalGetTeamCollaboratorsWithIdsImpl = do Store.getTeamCollaboratorsWithIds +internalRemoveTeamCollaboratorImpl :: + ( Member Store.TeamCollaboratorsStore r + ) => + UserId -> + TeamId -> + Sem r () +internalRemoveTeamCollaboratorImpl user team = do + Store.removeTeamCollaborator user team + -- This is of general usefulness. However, we cannot move this to wire-api as -- this would lead to a cyclic dependency. guardPermission :: diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs index 06ef6176bd..fab46b025d 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs @@ -27,3 +27,5 @@ inMemoryTeamCollaboratorsStoreInterpreter = GetTeamCollaboratorsWithIds teamIds userIds -> gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (concatMap (filter (\tc -> tc.gUser `elem` userIds)) . (\(tid :: TeamId) -> Map.lookup tid s)) teamIds + RemoveTeamCollaborator userId teamId -> + modify $ Map.alter (fmap $ filter $ (/= userId) . gUser) teamId diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index c6b7d5cd05..549f3a750a 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -33,3 +33,4 @@ teamMemberAPI = <@> mkNamedAPI @"delete-non-binding-team-member" deleteNonBindingTeamMember <@> mkNamedAPI @"update-team-member" updateTeamMember <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV + <@> mkNamedAPI @"remove-team-collaborator" removeTeamCollaborator diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index e51ab03a5c..652c6e1b3b 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -52,6 +52,7 @@ module Galley.API.Teams ensureNotTooLargeForLegalHold, ensureNotTooLargeToActivateLegalHold, internalDeleteBindingTeam, + removeTeamCollaborator, ) where @@ -100,7 +101,8 @@ import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog qualified as P import System.Logger qualified as Log -import Wire.API.Conversation (ConversationRemoveMembers (..)) +import Wire.API.Conversation (ConvType (..), ConversationRemoveMembers (..)) +import Wire.API.Conversation qualified import Wire.API.Conversation.Role (wireConvRoles) import Wire.API.Conversation.Role qualified as Public import Wire.API.Error @@ -109,8 +111,9 @@ import Wire.API.Event.Conversation qualified as Conv import Wire.API.Event.LeaveReason import Wire.API.Event.Team import Wire.API.Federation.Error +import Wire.API.Push.V2 (RecipientClients (RecipientClientsAll)) import Wire.API.Routes.Internal.Galley.TeamsIntra -import Wire.API.Routes.MultiTablePaging (MultiTablePage (MultiTablePage), MultiTablePagingState (mtpsState)) +import Wire.API.Routes.MultiTablePaging (MultiTablePage (..), MultiTablePagingState (mtpsState)) import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Team import Wire.API.Team qualified as Public @@ -931,27 +934,31 @@ removeFromConvsAndPushConvLeaveEvent lusr zcon tid remove = do for_ cc $ \c -> E.getConversation c >>= \conv -> for_ conv $ \dc -> - when (remove `isMember` dc.localMembers) $ do - E.deleteMembers c (UserList [remove] []) - let (bots, allLocUsers) = localBotsAndUsers (dc.localMembers) - targets = - BotsAndMembers - (Set.fromList $ (.id_) <$> allLocUsers) - (Set.fromList $ (.id_) <$> dc.remoteMembers) - (Set.fromList bots) - void $ - notifyConversationAction - (sing @'ConversationRemoveMembersTag) - (tUntagged lusr) - True - zcon - (qualifyAs lusr dc) - targets - ( ConversationRemoveMembers - (pure . tUntagged . qualifyAs lusr $ remove) - EdReasonDeleted - ) - def + when (remove `isMember` dc.localMembers) $ + case dc.metadata.cnvmType of + One2OneConv -> + E.deleteConversation dc.id_ + _ -> do + E.deleteMembers c (UserList [remove] []) + let (bots, allLocUsers) = localBotsAndUsers (dc.localMembers) + targets = + BotsAndMembers + (Set.fromList $ (.id_) <$> allLocUsers) + (Set.fromList $ (.id_) <$> dc.remoteMembers) + (Set.fromList bots) + void $ + notifyConversationAction + (sing @'ConversationRemoveMembersTag) + (tUntagged lusr) + True + zcon + (qualifyAs lusr dc) + targets + ( ConversationRemoveMembers + (pure . tUntagged . qualifyAs lusr $ remove) + EdReasonDeleted + ) + def getTeamConversations :: ( Member (ErrorS 'NotATeamMember) r, @@ -1297,3 +1304,50 @@ checkAdminLimit :: (Member (ErrorS 'TooManyTeamAdmins) r) => Int -> Sem r () checkAdminLimit adminCount = when (adminCount > 2000) $ throwS @'TooManyTeamAdmins + +-- | Removing a team collaborator and clean their conversations +removeTeamCollaborator :: + forall r. + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, + Member (Error FederationError) r, + Member (ErrorS OperationDenied) r, + Member (ErrorS NotATeamMember) r, + Member ExternalAccess r, + Member NotificationSubsystem r, + Member (Input Opts) r, + Member Now r, + Member P.TinyLog r, + Member TeamFeatureStore r, + Member TeamStore r, + Member TeamCollaboratorsSubsystem r + ) => + Local UserId -> + TeamId -> + UserId -> + Sem r () +removeTeamCollaborator lusr tid rusr = do + P.debug $ + Log.field "targets" (toByteString rusr) + . Log.field "action" (Log.val "Teams.removeTeamCollaborator") + zusrMember <- E.getTeamMember tid (tUnqualified lusr) + void $ permissionCheck RemoveTeamCollaborator zusrMember + toNotify <- + getFeatureForTeam @LimitedEventFanoutConfig tid + >>= ( \case + FeatureStatusEnabled -> Left <$> E.getTeamAdmins tid + FeatureStatusDisabled -> Right <$> getTeamMembersForFanout tid + ) + . (.status) + uncheckedDeleteTeamMember lusr Nothing tid rusr toNotify + internalRemoveTeamCollaborator rusr tid + now <- Now.get + let e = newEvent tid now (EdCollaboratorRemove rusr) + admins <- E.getTeamAdmins tid + pushNotifications + [ def + { origin = Just $ tUnqualified lusr, + json = toJSONObject e, + recipients = userRecipient rusr : map (`Recipient` RecipientClientsAll) admins + } + ] diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 99906c6a2a..a071d02a5b 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -274,6 +274,8 @@ evalGalley e = . runInputConst e . runInputConst (e ^. hasqlPool) . runInputConst (e ^. cstate) + . mapError toResponse + . mapError toResponse . mapError rateLimitExceededToHttpError . mapError toResponse -- DynError . interpretTinyLog e diff --git a/services/galley/src/Galley/Effects.hs b/services/galley/src/Galley/Effects.hs index 22e10cc0c4..57c132b6f5 100644 --- a/services/galley/src/Galley/Effects.hs +++ b/services/galley/src/Galley/Effects.hs @@ -88,6 +88,7 @@ import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog import Wire.API.Error +import Wire.API.Error.Galley import Wire.API.Team.Feature import Wire.BrigAPIAccess import Wire.ConversationStore (ConversationStore) @@ -147,5 +148,7 @@ type GalleyEffects1 = Queue DeleteItem, TinyLog, Error DynError, - Error RateLimitExceeded + Error RateLimitExceeded, + ErrorS OperationDenied, + ErrorS 'NotATeamMember ]