From 05bf163a7bdd2ad7edbd52026e8ed40486e7a9fb Mon Sep 17 00:00:00 2001 From: Paolo Capriotti Date: Mon, 17 Jul 2023 17:31:17 +0200 Subject: [PATCH 1/2] Do not send member updates to all (#3431) Co-authored-by: Stefan Matting --- services/galley/src/Galley/API/Internal.hs | 5 +- services/galley/src/Galley/API/Teams.hs | 97 ++++------- services/galley/test/integration/API/Teams.hs | 154 +----------------- services/galley/test/integration/API/Util.hs | 8 +- 4 files changed, 43 insertions(+), 221 deletions(-) diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index 2ec8b797ca6..86cc4793b94 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -61,6 +61,7 @@ import Galley.Effects.GundeckAccess import Galley.Effects.LegalHoldStore as LegalHoldStore import Galley.Effects.MemberStore qualified as E import Galley.Effects.TeamStore +import Galley.Effects.TeamStore qualified as E import Galley.Intra.Push qualified as Intra import Galley.Monad import Galley.Options hiding (brig) @@ -361,8 +362,8 @@ rmUser lusr conn = do goConvPages range newCids leaveTeams page = for_ (pageItems page) $ \tid -> do - mems <- getTeamMembersForFanout tid - uncheckedDeleteTeamMember lusr conn tid (tUnqualified lusr) mems + admins <- E.getTeamAdmins tid + uncheckedDeleteTeamMember lusr conn tid (tUnqualified lusr) admins page' <- listTeams @p2 (tUnqualified lusr) (Just (pageState page)) maxBound leaveTeams page' diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index a5aa8ecf9b4..98573b9ef86 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -332,10 +332,10 @@ updateTeamH zusr zcon tid updateData = do void $ permissionCheckS SSetTeamData zusrMembership E.setTeamData tid updateData now <- input - memList <- getTeamMembersForFanout tid + admins <- E.getTeamAdmins tid let e = newEvent tid now (EdTeamUpdate updateData) - let r = list1 (userRecipient zusr) (membersToRecipients (Just zusr) (memList ^. teamMembers)) - E.push1 $ newPushLocal1 (memList ^. teamMemberListType) zusr (TeamEvent e) r & pushConn ?~ zcon + let r = list1 (userRecipient zusr) (map userRecipient (filter (/= zusr) admins)) + E.push1 $ newPushLocal1 ListComplete zusr (TeamEvent e) r & pushConn ?~ zcon & pushTransient .~ True deleteTeam :: forall r. @@ -737,8 +737,7 @@ addTeamMember lzusr zcon tid nmem = do ensureConnectedToLocals zusr [uid] (TeamSize sizeBeforeJoin) <- E.getSize tid ensureNotTooLargeForLegalHold tid (fromIntegral sizeBeforeJoin + 1) - memList <- getTeamMembersForFanout tid - void $ addTeamMemberInternal tid (Just zusr) (Just zcon) nmem memList + void $ addTeamMemberInternal tid (Just zusr) (Just zcon) nmem -- This function is "unchecked" because there is no need to check for user binding (invite only). uncheckedAddTeamMember :: @@ -760,12 +759,11 @@ uncheckedAddTeamMember :: NewTeamMember -> Sem r () uncheckedAddTeamMember tid nmem = do - mems <- getTeamMembersForFanout tid (TeamSize sizeBeforeJoin) <- E.getSize tid ensureNotTooLargeForLegalHold tid (fromIntegral sizeBeforeJoin + 1) - (TeamSize sizeBeforeAdd) <- addTeamMemberInternal tid Nothing Nothing nmem mems - billingUserIds <- E.getBillingTeamMembers tid - Journal.teamUpdate tid (sizeBeforeAdd + 1) billingUserIds + (TeamSize sizeBeforeAdd) <- addTeamMemberInternal tid Nothing Nothing nmem + owners <- E.getBillingTeamMembers tid + Journal.teamUpdate tid (sizeBeforeAdd + 1) owners uncheckedUpdateTeamMember :: forall r. @@ -804,30 +802,15 @@ uncheckedUpdateTeamMember mlzusr mZcon tid newMember = do -- update target in Cassandra E.setTeamMemberPermissions (previousMember ^. permissions) tid targetId targetPermissions - updatedMembers <- getTeamMembersForFanout tid - updateJournal team - updatePeers mZusr targetId targetMember targetPermissions updatedMembers - where - updateJournal :: Team -> Sem r () - updateJournal team = do - when (team ^. teamBinding == Binding) $ do - (TeamSize size) <- E.getSize tid - owners <- E.getBillingTeamMembers tid - Journal.teamUpdate tid size owners - - updatePeers :: Maybe UserId -> UserId -> TeamMember -> Permissions -> TeamMemberList -> Sem r () - updatePeers zusr targetId targetMember targetPermissions updatedMembers = do - -- inform members of the team about the change - -- some (privileged) users will be informed about which change was applied - let privileged = filter (`canSeePermsOf` targetMember) (updatedMembers ^. teamMembers) - mkUpdate = EdMemberUpdate targetId - privilegedUpdate = mkUpdate $ Just targetPermissions - privilegedRecipients = membersToRecipients Nothing privileged - now <- input - let ePriv = newEvent tid now privilegedUpdate - -- push to all members (user is privileged) - let pushPriv = newPush (updatedMembers ^. teamMemberListType) zusr (TeamEvent ePriv) $ privilegedRecipients - for_ pushPriv (\p -> E.push1 (p & pushConn .~ mZcon)) + when (team ^. teamBinding == Binding) $ do + (TeamSize size) <- E.getSize tid + owners <- E.getBillingTeamMembers tid + Journal.teamUpdate tid size owners + + now <- input + let event = newEvent tid now (EdMemberUpdate targetId (Just targetPermissions)) + let pushPriv = newPush ListComplete mZusr (TeamEvent event) (map userRecipient admins') + for_ pushPriv (\p -> E.push1 (p & pushConn .~ mZcon & pushTransient .~ True)) updateTeamMember :: forall r. @@ -967,7 +950,6 @@ deleteTeamMember' lusr zcon tid remove mBody = do tm <- noteS @'TeamMemberNotFound targetMember unless (canDeleteMember dm tm) $ throwS @'AccessDenied team <- fmap tdTeam $ E.getTeam tid >>= noteS @'TeamNotFound - mems <- getTeamMembersForFanout tid if team ^. teamBinding == Binding && isJust targetMember then do body <- mBody & note (InvalidPayload "missing request body") @@ -985,7 +967,8 @@ deleteTeamMember' lusr zcon tid remove mBody = do Journal.teamUpdate tid sizeAfterDelete $ filter (/= remove) owners pure TeamMemberDeleteAccepted else do - uncheckedDeleteTeamMember lusr (Just zcon) tid remove mems + admins <- E.getTeamAdmins tid + uncheckedDeleteTeamMember lusr (Just zcon) tid remove admins pure TeamMemberDeleteCompleted -- This function is "unchecked" because it does not validate that the user has the `RemoveTeamMember` permission. @@ -1002,47 +985,43 @@ uncheckedDeleteTeamMember :: Maybe ConnId -> TeamId -> UserId -> - TeamMemberList -> + [UserId] -> Sem r () -uncheckedDeleteTeamMember lusr zcon tid remove mems = do +uncheckedDeleteTeamMember lusr zcon tid remove admins = do now <- input pushMemberLeaveEvent now E.deleteTeamMember tid remove removeFromConvsAndPushConvLeaveEvent now where - -- notify all team members. + -- notify team admins pushMemberLeaveEvent :: UTCTime -> Sem r () pushMemberLeaveEvent now = do let e = newEvent tid now (EdMemberLeave remove) let r = - list1 - (userRecipient (tUnqualified lusr)) - (membersToRecipients (Just (tUnqualified lusr)) (mems ^. teamMembers)) + userRecipient + <$> list1 + (tUnqualified lusr) + (filter (/= (tUnqualified lusr)) admins) E.push1 $ - newPushLocal1 (mems ^. teamMemberListType) (tUnqualified lusr) (TeamEvent e) r & pushConn .~ zcon + newPushLocal1 ListComplete (tUnqualified lusr) (TeamEvent e) r & pushConn .~ zcon & pushTransient .~ True -- notify all conversation members not in this team. removeFromConvsAndPushConvLeaveEvent :: UTCTime -> Sem r () removeFromConvsAndPushConvLeaveEvent now = do - -- This may not make sense if that list has been truncated. In such cases, we still want to - -- remove the user from conversations but never send out any events. We assume that clients - -- handle nicely these missing events, regardless of whether they are in the same team or not - let tmids = Set.fromList $ map (view userId) (mems ^. teamMembers) + let tmids = Set.fromList admins let edata = Conv.EdMembersLeave Conv.EdReasonDeleted (Conv.QualifiedUserIdList [tUntagged (qualifyAs lusr remove)]) cc <- E.getTeamConversations tid for_ cc $ \c -> E.getConversation (c ^. conversationId) >>= \conv -> for_ conv $ \dc -> when (remove `isMember` Data.convLocalMembers dc) $ do E.deleteMembers (c ^. conversationId) (UserList [remove] []) - -- If the list was truncated, then the tmids list is incomplete so we simply drop these events - unless (mems ^. teamMemberListType == ListTruncated) $ - pushEvent tmids edata now dc + pushEvent tmids edata now dc pushEvent :: Set UserId -> Conv.EventData -> UTCTime -> Data.Conversation -> Sem r () pushEvent exceptTo edata now dc = do let qconvId = tUntagged $ qualifyAs lusr (Data.convId dc) let (bots, users) = localBotsAndUsers (Data.convLocalMembers dc) let x = filter (\m -> not (Conv.lmId m `Set.member` exceptTo)) users let y = Conv.Event qconvId Nothing (tUntagged lusr) now edata - for_ (newPushLocal (mems ^. teamMemberListType) (tUnqualified lusr) (ConvEvent y) (recipient <$> x)) $ \p -> + for_ (newPushLocal ListComplete (tUnqualified lusr) (ConvEvent y) (recipient <$> x)) $ \p -> E.push1 $ p & pushConn .~ zcon E.deliverAsync (map (,y) bots) @@ -1264,9 +1243,8 @@ addTeamMemberInternal :: Maybe UserId -> Maybe ConnId -> NewTeamMember -> - TeamMemberList -> Sem r TeamSize -addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) memList = do +addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do P.debug $ Log.field "targets" (toByteString (new ^. userId)) . Log.field "action" (Log.val "Teams.addTeamMemberInternal") @@ -1277,22 +1255,17 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) memList = checkAdminLimit (length admins') E.createTeamMember tid new + now <- input let e = newEvent tid now (EdMemberJoin (new ^. userId)) + let rs = case origin of + Just o -> userRecipient <$> list1 o (filter (/= o) ((new ^. userId) : admins')) + Nothing -> userRecipient <$> list1 (new ^. userId) (admins') E.push1 $ - newPushLocal1 (memList ^. teamMemberListType) (new ^. userId) (TeamEvent e) (recipients origin new) & pushConn .~ originConn + newPushLocal1 ListComplete (new ^. userId) (TeamEvent e) rs & pushConn .~ originConn & pushTransient .~ True APITeamQueue.pushTeamEvent tid e pure sizeBeforeAdd - where - recipients (Just o) n = - list1 - (userRecipient o) - (membersToRecipients (Just o) (n : memList ^. teamMembers)) - recipients Nothing n = - list1 - (userRecipient (n ^. userId)) - (membersToRecipients Nothing (memList ^. teamMembers)) finishCreateTeam :: ( Member GundeckAccess r, diff --git a/services/galley/test/integration/API/Teams.hs b/services/galley/test/integration/API/Teams.hs index 8b217f515f0..0764e84e450 100644 --- a/services/galley/test/integration/API/Teams.hs +++ b/services/galley/test/integration/API/Teams.hs @@ -153,7 +153,6 @@ tests s = test s "update team data icon validation" testUpdateTeamIconValidation, test s "update team member" testUpdateTeamMember, test s "update team status" testUpdateTeamStatus, - test s "team tests around truncation limits - no events, too large team" testTeamAddRemoveMemberAboveThresholdNoEvents, test s "send billing events to owners even in large teams" testBillingInLargeTeam, testGroup "broadcast" $ [ (BroadcastLegacyBody, BroadcastJSON), @@ -1351,7 +1350,6 @@ testUpdateTeam = do WS.bracketR2 c owner member $ \(wsOwner, wsMember) -> do doPut (encode u) 200 checkTeamUpdateEvent tid u wsOwner - checkTeamUpdateEvent tid u wsMember WS.assertNoEvent timeout [wsOwner, wsMember] t <- Util.getTeam owner tid liftIO $ assertEqual "teamSplashScreen" (t ^. teamSplashScreen) (fromJust $ fromByteString "3-1-e1c89a56-882e-4694-bab3-c4f57803c57a") @@ -1369,154 +1367,6 @@ testUpdateTeam = do t' <- Util.getTeam owner tid liftIO $ assertEqual "teamSplashScreen" (t' ^. teamSplashScreen) DefaultIcon -testTeamAddRemoveMemberAboveThresholdNoEvents :: HasCallStack => TestM () -testTeamAddRemoveMemberAboveThresholdNoEvents = do - localDomain <- viewFederationDomain - o <- view tsGConf - c <- view tsCannon - let fanoutLimit = fromIntegral . fromRange $ Galley.currentFanoutLimit o - (owner, tid) <- Util.createBindingTeam - member1 <- addTeamMemberAndExpectEvent True tid owner - -- Now last fill the team until truncationSize - 2 - - replicateM_ (fanoutLimit - 4) $ Util.addUserToTeam owner tid - (extern, qextern) <- Util.randomUserTuple - modifyTeamDataAndExpectEvent True tid owner - -- Let's create and remove a member - member2 <- do - temp <- addTeamMemberAndExpectEvent True tid owner - Util.connectUsers extern (list1 temp []) - removeTeamMemberAndExpectEvent True owner tid temp [extern] - addTeamMemberAndExpectEvent True tid owner - modifyUserProfileAndExpectEvent True owner [member1, member2] - -- Let's connect an external to test the different behavior - Util.connectUsers extern (list1 owner [member1, member2]) - _memLastWithFanout <- addTeamMemberAndExpectEvent True tid owner - -- We should really wait until we see that the team is of full size - -- Due to the async nature of pushes, waiting even a second might not - -- be enough... - WS.bracketR c owner $ \wsOwner -> WS.assertNoEvent (1 # Second) [wsOwner] - -- No events are now expected - - -- Team member added also not - _memWithoutFanout <- addTeamMemberAndExpectEvent False tid owner - -- Team updates are not propagated - modifyTeamDataAndExpectEvent False tid owner - -- User event updates are not propagated in the team - modifyUserProfileAndExpectEvent False owner [member1, member2] - -- Let us remove 1 member that exceeds the limit, verify that team users - -- do not get the deletion event but the connections do! - removeTeamMemberAndExpectEvent False owner tid member2 [extern] - -- Now we are just on the limit, events are back! - removeTeamMemberAndExpectEvent True owner tid member1 [extern] - -- Let's go back to having a very large team - _memLastWithFanout <- addTeamMemberAndExpectEvent True tid owner - -- We should really wait until we see that the team is of full size - -- Due to the async nature of pushes, waiting even a second might not - -- be enough... - WS.bracketR c owner $ \wsOwner -> WS.assertNoEvent (1 # Second) [wsOwner] - _memWithoutFanout <- addTeamMemberAndExpectEvent False tid owner - -- Add extern to a team conversation - cid1 <- Util.createTeamConv owner tid [] (Just "blaa") Nothing Nothing - qcid1 <- Qualified cid1 <$> viewFederationDomain - Util.postMembers owner (pure qextern) qcid1 !!! const 200 === statusCode - -- Test team deletion (should contain only conv. removal and user.deletion for _non_ team members) - deleteTeam tid owner [] [Qualified cid1 localDomain] extern - where - modifyUserProfileAndExpectEvent :: HasCallStack => Bool -> UserId -> [UserId] -> TestM () - modifyUserProfileAndExpectEvent expect target listeners = do - c <- view tsCannon - b <- viewBrig - WS.bracketRN c listeners $ \wsListeners -> do - -- Do something - let u = U.UserUpdate (Just $ U.Name "name") Nothing Nothing Nothing - put - ( b - . paths ["self"] - . zUser target - . zConn "conn" - . json u - ) - !!! const 200 - === statusCode - if expect - then mapM_ (checkUserUpdateEvent target) wsListeners - else WS.assertNoEvent (1 # Second) wsListeners - modifyTeamDataAndExpectEvent :: HasCallStack => Bool -> TeamId -> UserId -> TestM () - modifyTeamDataAndExpectEvent expect tid origin = do - c <- view tsCannon - g <- viewGalley - let u = newTeamUpdateData & nameUpdate ?~ unsafeRange "bar" - WS.bracketR c origin $ \wsOrigin -> do - put - ( g - . paths ["teams", toByteString' tid] - . zUser origin - . zConn "conn" - . json u - ) - !!! const 200 - === statusCode - -- Due to the fact that the team is too large, we expect no events! - if expect - then checkTeamUpdateEvent tid u wsOrigin - else WS.assertNoEvent (1 # Second) [wsOrigin] - addTeamMemberAndExpectEvent :: HasCallStack => Bool -> TeamId -> UserId -> TestM UserId - addTeamMemberAndExpectEvent expect tid origin = do - c <- view tsCannon - WS.bracketR c origin $ \wsOrigin -> do - member <- view userId <$> Util.addUserToTeam origin tid - refreshIndex - if expect - then checkTeamMemberJoin tid member wsOrigin - else WS.assertNoEvent (1 # Second) [wsOrigin] - pure member - removeTeamMemberAndExpectEvent :: HasCallStack => Bool -> UserId -> TeamId -> UserId -> [UserId] -> TestM () - removeTeamMemberAndExpectEvent expect owner tid victim others = do - c <- view tsCannon - g <- viewGalley - WS.bracketRN c (owner : victim : others) $ \(wsOwner : _wsVictim : wsOthers) -> do - delete - ( g - . paths ["teams", toByteString' tid, "members", toByteString' victim] - . zUser owner - . zConn "conn" - . json (newTeamMemberDeleteData (Just $ Util.defPassword)) - ) - !!! const 202 - === statusCode - if expect - then checkTeamMemberLeave tid victim wsOwner - else WS.assertNoEvent (1 # Second) [wsOwner] - -- User deletion events - mapM_ (checkUserDeleteEvent victim checkTimeout) wsOthers - Util.ensureDeletedState True owner victim - deleteTeam :: HasCallStack => TeamId -> UserId -> [UserId] -> [Qualified ConvId] -> UserId -> TestM () - deleteTeam tid owner otherRealUsersInTeam teamCidsThatExternBelongsTo extern = do - c <- view tsCannon - g <- viewGalley - void . WS.bracketRN c (owner : extern : otherRealUsersInTeam) $ \(_wsOwner : wsExtern : _wsotherRealUsersInTeam) -> do - delete - ( g - . paths ["teams", toByteString' tid] - . zUser owner - . zConn "conn" - . json (newTeamDeleteData (Just Util.defPassword)) - ) - !!! const 202 - === statusCode - for_ (owner : otherRealUsersInTeam) $ \u -> checkUserDeleteEvent u (7 # Second) wsExtern - -- Ensure users are marked as deleted; since we already - -- received the event, should _really_ be deleted - for_ (owner : otherRealUsersInTeam) $ Util.ensureDeletedState True extern - mapM_ (flip checkConvDeleteEvent wsExtern) teamCidsThatExternBelongsTo - -- ensure the team has a deleted status - void $ - retryWhileN - 10 - ((/= TeamsIntra.Deleted) . TeamsIntra.tdStatus) - (getTeamInternal tid) - testBillingInLargeTeam :: TestM () testBillingInLargeTeam = do (firstOwner, team) <- Util.createBindingTeam @@ -1576,7 +1426,6 @@ testUpdateTeamMember = do member' <- Util.getTeamMember owner tid (member ^. userId) liftIO $ assertEqual "permissions" (member' ^. permissions) (demoteMember ^. nPermissions) checkTeamMemberUpdateEvent tid (member ^. userId) wsOwner (pure noPermissions) - checkTeamMemberUpdateEvent tid (member ^. userId) wsMember (pure noPermissions) WS.assertNoEvent timeout [wsOwner, wsMember] assertTeamUpdate "Member demoted" tid 2 [owner] -- owner can promote non-owner @@ -1614,7 +1463,6 @@ testUpdateTeamMember = do . json change ) checkTeamMemberUpdateEvent tid uid w mPerm = WS.assertMatch_ timeout w $ \notif -> do - ntfTransient notif @?= False let e = List1.head (WS.unpackPayload notif) e ^. eventTeam @?= tid e ^. eventData @?= EdMemberUpdate uid mPerm @@ -1919,7 +1767,7 @@ putSearchVisibility g uid tid vis = do checkJoinEvent :: (MonadIO m, MonadCatch m) => TeamId -> UserId -> WS.WebSocket -> m () checkJoinEvent tid usr w = WS.assertMatch_ timeout w $ \notif -> do - ntfTransient notif @?= False + ntfTransient notif @?= True let e = List1.head (WS.unpackPayload notif) e ^. eventTeam @?= tid e ^. eventData @?= EdMemberJoin usr diff --git a/services/galley/test/integration/API/Util.hs b/services/galley/test/integration/API/Util.hs index ba24e3da46a..97e3e61d70b 100644 --- a/services/galley/test/integration/API/Util.hs +++ b/services/galley/test/integration/API/Util.hs @@ -2761,21 +2761,21 @@ checkUserDeleteEvent uid timeout_ w = WS.assertMatch_ timeout_ w $ \notif -> do checkTeamMemberJoin :: HasCallStack => TeamId -> UserId -> WS.WebSocket -> TestM () checkTeamMemberJoin tid uid w = WS.awaitMatch_ checkTimeout w $ \notif -> do - ntfTransient notif @?= False + ntfTransient notif @?= True let e = List1.head (WS.unpackPayload notif) e ^. eventTeam @?= tid e ^. eventData @?= EdMemberJoin uid checkTeamMemberLeave :: HasCallStack => TeamId -> UserId -> WS.WebSocket -> TestM () checkTeamMemberLeave tid usr w = WS.assertMatch_ checkTimeout w $ \notif -> do - ntfTransient notif @?= False + ntfTransient notif @?= True let e = List1.head (WS.unpackPayload notif) e ^. eventTeam @?= tid e ^. eventData @?= EdMemberLeave usr checkTeamUpdateEvent :: (HasCallStack, MonadIO m, MonadCatch m) => TeamId -> TeamUpdateData -> WS.WebSocket -> m () checkTeamUpdateEvent tid upd w = WS.assertMatch_ checkTimeout w $ \notif -> do - ntfTransient notif @?= False + ntfTransient notif @?= True let e = List1.head (WS.unpackPayload notif) e ^. eventTeam @?= tid e ^. eventData @?= EdTeamUpdate upd @@ -2847,7 +2847,7 @@ checkConvMemberLeaveEvent cid usr w = WS.assertMatch_ checkTimeout w $ \notif -> other -> assertFailure $ "Unexpected event data: " <> show other checkTimeout :: WS.Timeout -checkTimeout = 4 # Second +checkTimeout = 60 # Second -- | The function is used in conjuction with 'withTempMockFederator' to mock -- responses by Brig on the mocked side of federation. From 1b104d6a72a8688ef731679270efe85a5c4236ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Dimja=C5=A1evi=C4=87?= Date: Thu, 9 Nov 2023 14:23:14 +0100 Subject: [PATCH 2/2] Add a changelog --- changelog.d/2-features/WPB-2565-member-updates | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/2-features/WPB-2565-member-updates diff --git a/changelog.d/2-features/WPB-2565-member-updates b/changelog.d/2-features/WPB-2565-member-updates new file mode 100644 index 00000000000..87cbab74fa0 --- /dev/null +++ b/changelog.d/2-features/WPB-2565-member-updates @@ -0,0 +1 @@ +Events for a member update, join and leave are not sent to everyone in the team any longer. Only team admins get them.