From 34da9218a268dedf740a7f8a40ee24a9488fa9fa Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 17 Aug 2026 15:48:53 +0200 Subject: [PATCH 01/16] Changelog. --- ...-treat-team-collaborators-like-team-members-in-contact-search | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search diff --git a/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search b/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search new file mode 100644 index 0000000000..d66924aaee --- /dev/null +++ b/changelog.d/1-api-changes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search @@ -0,0 +1 @@ +Treat team collaborators like team members in contact search. From 7a7c74601881530648efa94d3552cc96238ac98e Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 17 Aug 2026 15:49:01 +0200 Subject: [PATCH 02/16] Failing integration test. --- integration/test/Test/TeamCollaborators.hs | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index cf55c3a558..55a8497401 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -1,3 +1,5 @@ +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2025 Wire Swiss GmbH @@ -17,6 +19,9 @@ module Test.TeamCollaborators where +import qualified API.Brig as BrigP +import qualified API.BrigInternal as BrigI +import API.Common (randomName) import API.Galley import qualified API.GalleyInternal as Internal import Data.Tuple.Extra @@ -317,3 +322,94 @@ testUpdateCollaborator = do [] >>= assertSuccess postOne2OneConversation bob alice team "chit-chat" >>= assertLabel 403 "operation-denied" + +-- | Collaborators are part of the search space of the team they +-- collaborate with: `GET /search/contacts` returns them to members of +-- that team, just like it returns the team's own members. We test +-- collaborators from other teams, personal user accounts that +-- collaborate, and app. +testSearchFindsCollaborator :: (HasCallStack) => App () +testSearchFindsCollaborator = do + (owner, team, [alice]) <- createTeam OwnDomain 2 + (otherOwner, otherTeam, [bob, collab1]) <- createTeam OwnDomain 3 + collab2 :: Value <- randomUser OwnDomain def + collab3 :: Value <- + BrigP.createApp otherOwner otherTeam def + `bindResponse` \resp -> resp.json %. "user" + + collab1Name <- collab1 %. "name" & asString + collab2Name <- collab2 %. "name" & asString + collab3Name <- collab3 %. "name" & asString + + collab1Name' <- randomName + collab2Name' <- randomName + collab3Name' <- randomName + + -- Find before any collaborations have been established. + let assertFinds :: + (HasCallStack, MakesValue expectFound, MakesValue searcher) => + String -> + expectFound -> + searcher -> + App () + assertFinds searchTerm expectFound searcher = do + BrigI.refreshIndex OwnDomain + BrigP.searchContacts searcher searchTerm OwnDomain `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + foundIds :: [String] <- resp.json %. "documents" >>= asList >>= mapM objId + expectedIds :: [String] <- (make >=> asList >=> mapM objId) expectFound + assertBool + ("found: " <> show foundIds <> "; expected: " <> show expectedIds) + (sort foundIds == sort expectedIds) + + for_ [owner, alice] $ assertFinds collab1Name ([] @Value) + for_ [otherOwner, bob] $ assertFinds collab1Name [collab1] + + for_ [owner, alice] $ assertFinds collab2Name [collab2] + for_ [otherOwner, bob] $ assertFinds collab2Name [collab2] + + for_ [owner, alice] $ assertFinds collab3Name ([] @Value) + for_ [otherOwner, bob] $ assertFinds collab3Name [collab3] + + -- Add collaborators to team + for_ [collab1, collab2, collab3] + $ \collab -> + addTeamCollaborator owner team collab ["implicit_connection"] >>= assertSuccess + + for_ [owner, alice] $ assertFinds collab1Name [collab1] + for_ [otherOwner, bob] $ assertFinds collab1Name [collab1] + + for_ [owner, alice] $ assertFinds collab2Name [collab2] + for_ [otherOwner, bob] $ assertFinds collab2Name [collab2] + + for_ [owner, alice] $ assertFinds collab3Name [collab3] + for_ [otherOwner, bob] $ assertFinds collab3Name [collab3] + + -- Check that updating name does not erase collaborating teams in index. + for_ [(collab1, collab1Name'), (collab2, collab2Name'), (collab3, collab3Name')] + $ \(collab, newName) -> do + let updateBody = (def :: BrigP.PutSelf) {BrigP.name = Just newName} + in BrigP.putSelf collab updateBody >>= assertSuccess + + for_ [owner, alice] $ assertFinds collab1Name' [collab1] + for_ [otherOwner, bob] $ assertFinds collab1Name' [collab1] + + for_ [owner, alice] $ assertFinds collab2Name' [collab2] + for_ [otherOwner, bob] $ assertFinds collab2Name' [collab2] + + for_ [owner, alice] $ assertFinds collab3Name' [collab3] + for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3] + + -- Check that updating collaborating teams does not erase name in index. + for_ [collab1, collab2, collab3] + $ \collab -> do + removeTeamCollaborator owner team collab >>= assertSuccess + + for_ [owner, alice] $ assertFinds collab1Name' ([] @Value) + for_ [otherOwner, bob] $ assertFinds collab1Name' [collab1] + + for_ [owner, alice] $ assertFinds collab2Name' [collab2] + for_ [otherOwner, bob] $ assertFinds collab2Name' [collab2] + + for_ [owner, alice] $ assertFinds collab3Name' ([] @Value) + for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3] From 65a81ce78a92a3c91cdc553c3840a388ab8dc38a Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 24 Aug 2026 12:51:24 +0200 Subject: [PATCH 03/16] Include team collaborators in contact search. --- .../src/Wire/AppSubsystem/Interpreter.hs | 4 ++-- .../IndexedUserStore/Bulk/ElasticSearch.hs | 2 +- .../Wire/IndexedUserStore/ElasticSearch.hs | 11 +++++++++- .../TeamCollaboratorsSubsystem/Interpreter.hs | 22 +++++++++++++++---- .../src/Wire/UserSearch/Types.hs | 9 ++++++-- .../src/Wire/UserStore/IndexUser.hs | 8 ++++--- .../wire-subsystems/src/Wire/UserSubsystem.hs | 4 ++-- .../src/Wire/UserSubsystem/Interpreter.hs | 19 ++++++++-------- .../Wire/MockInterpreters/UserSubsystem.hs | 2 +- .../test/unit/Wire/UserSearch/TypesSpec.hs | 1 + .../Wire/UserSubsystem/InterpreterSpec.hs | 2 +- services/brig/src/Brig/API/Internal.hs | 6 ++--- services/brig/src/Brig/API/User.hs | 14 ++++++------ 13 files changed, 68 insertions(+), 36 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs index e1dd13ff30..2cac7678e7 100644 --- a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs @@ -129,7 +129,7 @@ createAppImpl lusr tid newApp = do Store.createUser u Nothing now <- toUTCTimeMillis <$> get void $ addTeamMember u.id tid (Just (tUnqualified lusr, now)) R.RoleMember - internalUpdateSearchIndex u.id + internalUpdateSearchIndex u.id Nothing -- generate a team event generateTeamEvents creator.id tid [EdMemberJoin u.id] @@ -214,7 +214,7 @@ updateAppImpl lusr tid appid upd = do Right () -> pure () Left Store.NotFound -> throw AppSubsystemErrorNoApp Store.updateUser appid (def {Store.name = upd.name, Store.assets = upd.assets, Store.accentId = upd.accentId}) - internalUpdateSearchIndex appid + internalUpdateSearchIndex appid Nothing generateUserEvent appid Nothing $ UserUpdated $ (emptyUserUpdatedData appid) diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs index 6317ed7ba2..2142d93aaa 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs @@ -122,7 +122,7 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = mkUserDoc indexUser = do currentVis <- vis indexUser currentRole <- sequence $ Map.lookup indexUser.userId roles - pure $ indexUserToDoc currentVis ((.value) <$> currentRole) indexUser + pure $ indexUserToDoc currentVis ((.value) <$> currentRole) Nothing indexUser mkDocVersion :: IndexUser -> Either SomeException ES.VersionControl mkDocVersion u = do diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index 156f8f6e47..ec3fc34aeb 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -640,7 +640,16 @@ restrictSearchSpaceByUserType = \case else ES.TermsQuery "type" (userTypeFilterToText <$> (utsH :| utsT)) matchTeamMembersOf :: TeamId -> ES.Query -matchTeamMembersOf team = ES.TermQuery (ES.Term "team" $ idToText team) Nothing +matchTeamMembersOf team = + ES.QueryBoolQuery + boolQuery + { ES.boolQueryShouldMatch = + [ -- Match users who are members of the team + ES.TermQuery (ES.Term "team" $ idToText team) Nothing, + -- Match users who are collaborators in the team + ES.TermQuery (ES.Term "collaborating_teams" $ idToText team) Nothing + ] + } matchTeamMembersSearchableByAllTeams :: ES.Query matchTeamMembersSearchableByAllTeams = diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs index 30a970706e..af094594ab 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs @@ -36,13 +36,16 @@ import Wire.TeamCollaboratorsStore qualified as Store import Wire.TeamCollaboratorsSubsystem import Wire.TeamSubsystem import Wire.TeamSubsystem.Util +import Wire.UserSubsystem (UserSubsystem) +import Wire.UserSubsystem qualified as UserSubsystem interpretTeamCollaboratorsSubsystem :: ( Member TeamSubsystem r, Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, - Member NotificationSubsystem r + Member NotificationSubsystem r, + Member UserSubsystem r ) => InterpreterFor TeamCollaboratorsSubsystem r interpretTeamCollaboratorsSubsystem = interpret $ \case @@ -74,7 +77,8 @@ createTeamCollaboratorImpl :: Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, - Member NotificationSubsystem r + Member NotificationSubsystem r, + Member UserSubsystem r ) => Local UserId -> UserId -> @@ -88,6 +92,10 @@ createTeamCollaboratorImpl zUser user team perms = do -- TODO: Review the event's values generateTeamEvents (tUnqualified zUser) team [EdCollaboratorAdd user (Set.toList perms)] + -- Reindex the collaborator with their new collaboration team + collaborations <- Store.getTeamCollaborations user + UserSubsystem.internalUpdateSearchIndex user (Just (map gTeam collaborations)) + getAllTeamCollaboratorsImpl :: ( Member TeamSubsystem r, Member (Error TeamCollaboratorsError) r, @@ -109,21 +117,27 @@ internalGetTeamCollaboratorsWithIdsImpl = do Store.getTeamCollaboratorsWithIds internalUpdateTeamCollaboratorImpl :: - (Member Store.TeamCollaboratorsStore r) => + (Member Store.TeamCollaboratorsStore r, Member UserSubsystem r) => UserId -> TeamId -> Set CollaboratorPermission -> Sem r () internalUpdateTeamCollaboratorImpl user team perms = do Store.updateTeamCollaborator user team perms + -- Reindex collaborator when permissions change + collaborations <- Store.getTeamCollaborations user + UserSubsystem.internalUpdateSearchIndex user (Just (map gTeam collaborations)) internalRemoveTeamCollaboratorImpl :: - (Member Store.TeamCollaboratorsStore r) => + (Member Store.TeamCollaboratorsStore r, Member UserSubsystem r) => UserId -> TeamId -> Sem r () internalRemoveTeamCollaboratorImpl user team = do Store.removeTeamCollaborator user team + -- Reindex collaborator when removed + collaborations <- Store.getTeamCollaborations user + UserSubsystem.internalUpdateSearchIndex user (Just (map gTeam collaborations)) -- This is of general usefulness. However, we cannot move this to wire-api as -- this would lead to a cyclic dependency. diff --git a/libs/wire-subsystems/src/Wire/UserSearch/Types.hs b/libs/wire-subsystems/src/Wire/UserSearch/Types.hs index 5e8dcac765..58ab0de81e 100644 --- a/libs/wire-subsystems/src/Wire/UserSearch/Types.hs +++ b/libs/wire-subsystems/src/Wire/UserSearch/Types.hs @@ -79,7 +79,10 @@ data UserDoc = UserDoc udScimExternalId :: Maybe Text, udSso :: Maybe Sso, udEmailUnvalidated :: Maybe EmailAddress, - udSearchable :: Maybe Bool + udSearchable :: Maybe Bool, + -- | Teams that have added this user as a collaborator. + -- Updated separately via 'syncUserIndexCollaborations' when collaborator relationships change. + udCollaboratingTeams :: Maybe [TeamId] } deriving (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserDoc) @@ -104,7 +107,8 @@ instance ToJSON UserDoc where "scim_external_id" .= udScimExternalId ud, "sso" .= udSso ud, "email_unvalidated" .= udEmailUnvalidated ud, - "searchable" .= udSearchable ud + "searchable" .= udSearchable ud, + "collaborating_teams" .= udCollaboratingTeams ud ] instance FromJSON UserDoc where @@ -128,6 +132,7 @@ instance FromJSON UserDoc where <*> o .:? "sso" <*> o .:? "email_unvalidated" <*> o .:? "searchable" + <*> o .:? "collaborating_teams" searchVisibilityInboundFieldName :: Key searchVisibilityInboundFieldName = "search_visibility_inbound" diff --git a/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs b/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs index 09ac630d19..1d54a2da1c 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs @@ -126,8 +126,8 @@ indexUserToVersion :: Maybe (WithWritetime Role) -> IndexUser -> IndexVersion indexUserToVersion role iu = mkIndexVersion [Just $ Writetime iu.updatedAt, const () <$$> fmap writetime role] -indexUserToDoc :: SearchVisibilityInbound -> Maybe Role -> IndexUser -> UserDoc -indexUserToDoc searchVisInbound mRole IndexUser {..} = +indexUserToDoc :: SearchVisibilityInbound -> Maybe Role -> Maybe [TeamId] -> IndexUser -> UserDoc +indexUserToDoc searchVisInbound mRole mCollaboratingTeams IndexUser {..} = if shouldIndex then UserDoc @@ -148,7 +148,8 @@ indexUserToDoc searchVisInbound mRole IndexUser {..} = udHandle = handle, udNormalized = Just $ normalized name.fromName, udName = Just name, - udTeam = teamId + udTeam = teamId, + udCollaboratingTeams = mCollaboratingTeams } else -- We insert a tombstone-style user here, as it's easier than -- deleting the old one. It's mostly empty, but having the status here @@ -209,5 +210,6 @@ emptyUserDoc uid = udNormalized = Nothing, udName = Nothing, udTeam = Nothing, + udCollaboratingTeams = Nothing, udId = uid } diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem.hs b/libs/wire-subsystems/src/Wire/UserSubsystem.hs index 0f5f428ae7..5988c58573 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem.hs @@ -180,7 +180,7 @@ data UserSubsystem m a where AcceptTeamInvitation :: Local UserId -> PlainTextPassword6 -> InvitationCode -> UserSubsystem m () -- | The following "internal" functions exists to support migration in this susbystem, after the -- migration this would just be an internal detail of the subsystem - InternalUpdateSearchIndex :: UserId -> UserSubsystem m () + InternalUpdateSearchIndex :: UserId -> Maybe [TeamId] -> UserSubsystem m () InternalFindTeamInvitation :: Maybe EmailKey -> InvitationCode -> UserSubsystem m StoredInvitation GetUserExportData :: UserId -> UserSubsystem m (Maybe TeamExportUser) RemoveEmailEither :: Local UserId -> UserSubsystem m (Either UserSubsystemError ()) @@ -272,7 +272,7 @@ requestEmailChange lusr email allowScim = do ChangeEmailNeedsActivation (usr, adata, en) -> do sendOutEmail usr adata en updateEmailUnvalidated u email - internalUpdateSearchIndex u + internalUpdateSearchIndex u Nothing pure ChangeEmailResponseNeedsActivation where throwGuardFailed :: diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index d5cb2dfee6..d766491d85 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -194,8 +194,8 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = isUsersContactableImpl users mlsAvailable allowedCipherSuites BrowseTeam uid browseTeamFilters mMaxResults mPagingState -> browseTeamImpl uid browseTeamFilters mMaxResults mPagingState - InternalUpdateSearchIndex uid -> - syncUserIndex uid + InternalUpdateSearchIndex uid mTeams -> + syncUserIndex uid mTeams AcceptTeamInvitation luid pwd code -> acceptTeamInvitationImpl luid pwd code InternalFindTeamInvitation mEmailKey code -> @@ -725,7 +725,7 @@ updateUserProfileImpl (tUnqualified -> uid) mconn updateOrigin update = do mapError (\StoredUserUpdateHandleExists -> UserSubsystemHandleExists) $ updateUser uid (storedUserUpdate update) let interestingToUpdateIndex = isJust update.name || isJust update.accentId - when interestingToUpdateIndex $ syncUserIndex uid + when interestingToUpdateIndex $ syncUserIndex uid Nothing generateUserEvent uid mconn (mkProfileUpdateEvent uid update) where guardMlsSupport user = for_ update.supportedProtocols $ \protocols -> do @@ -789,7 +789,7 @@ updateHandleImpl (tUnqualified -> uid) mconn updateOrigin uhandle = do throw UserSubsystemNoIdentity mapError (\StoredUserUpdateHandleExists -> UserSubsystemHandleExists) $ UserStore.updateUserHandle uid (MkStoredUserHandleUpdate user.handle newHandle) - syncUserIndex uid + syncUserIndex uid Nothing generateUserEvent uid mconn (mkProfileUpdateHandleEvent uid newHandle) checkHandleImpl :: (Member (Error UserSubsystemError) r, Member UserStore r) => Text -> Sem r CheckHandleResp @@ -842,8 +842,9 @@ syncUserIndex :: Member Metrics r ) => UserId -> + Maybe [TeamId] -> Sem r () -syncUserIndex uid = +syncUserIndex uid mCollabTeams = getIndexUser uid >>= maybe deleteFromIndex upsert where @@ -861,7 +862,7 @@ syncUserIndex uid = indexUser.teamId tm <- maybe (pure Nothing) selectTeamMember indexUser.teamId let mRole = tm >>= mkRoleWithWriteTime - userDoc = indexUserToDoc vis (value <$> mRole) indexUser + userDoc = indexUserToDoc vis (value <$> mRole) mCollabTeams indexUser version = ES.ExternalGT . ES.ExternalDocVersion . docVersion $ indexUserToVersion mRole indexUser Metrics.incCounter indexUpdateCounter IndexedUserStore.upsert (userIdToDocId uid) userDoc version @@ -1208,7 +1209,7 @@ acceptTeamInvitationImpl luid pw code = do deleteInvitation inv.teamId inv.invitationId for_ (userEmail . selfUser =<< mSelfProfile) $ \email -> deletePendingScimUser tid email uid - syncUserIndex uid + syncUserIndex uid Nothing generateUserEvent uid Nothing (teamUpdated uid tid) getUserExportDataImpl :: (Member UserStore r, Member ClientSubsystem r) => UserId -> Sem r (Maybe TeamExportUser) @@ -1258,7 +1259,7 @@ removeEmailEitherImpl lusr = runError $ do deleteKey $ mkEmailKey e deleteEmail uid generateUserEvent uid Nothing (emailRemoved uid e) - syncUserIndex uid + syncUserIndex uid Nothing Just _ -> throw UserSubsystemLastIdentity Nothing -> throw UserSubsystemNoIdentity @@ -1290,4 +1291,4 @@ setUserSearchableImpl luid uid searchable = do tid <- maybe (throw UserSubsystemInsufficientPermissions) pure =<< UserStore.getUserTeam uid ensurePermissions (tUnqualified luid) tid [SetMemberSearchable] UserStore.setUserSearchable uid searchable - syncUserIndex uid + syncUserIndex uid Nothing diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs index 786f733240..043f3a834d 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs @@ -87,7 +87,7 @@ inMemoryUserSubsystemInterpreter = BlockListInsert _ -> error "BlockListInsert: implement on demand (userSubsystemInterpreter)" UpdateTeamSearchVisibilityInbound _ -> error "UpdateTeamSearchVisibilityInbound: implement on demand (userSubsystemInterpreter)" AcceptTeamInvitation {} -> error "AcceptTeamInvitation: implement on demand (userSubsystemInterpreter)" - InternalUpdateSearchIndex _ -> error "InternalUpdateSearchIndex: implement on demand (userSubsystemInterpreter)" + InternalUpdateSearchIndex {} -> error "InternalUpdateSearchIndex: implement on demand (userSubsystemInterpreter)" InternalFindTeamInvitation {} -> error "InternalFindTeamInvitation: implement on demand (userSubsystemInterpreter)" GetUserExportData _ -> error "GetUserExportData: implement on demand (userSubsystemInterpreter)" RemoveEmailEither _ -> error "RemoveEmailEither: implement on demand (userSubsystemInterpreter)" diff --git a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs index a09d56bd8f..413c09d0bd 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs @@ -50,6 +50,7 @@ userDoc1 = UserDoc { udId = fromJust . hush . parseIdFromText $ "0a96b396-57d6-11ea-a04b-7b93d1a5c19c", udTeam = hush . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", + udCollaboratingTeams = fmap (: []) . hush . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", udName = Just . Name $ "Carl Phoomp", udNormalized = Just $ "carl phoomp", udHandle = Just . fromJust . parseHandle $ "phoompy", diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index 68942a77cb..faca707880 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -1110,7 +1110,7 @@ spec = describe "UserSubsystem.Interpreter" do searchee = searcheeNoHandle {handle = Just searcheeHandle} :: StoredUser storedUserToDoc :: StoredUser -> UserDoc - storedUserToDoc user = indexUserToDoc defaultSearchVisibilityInbound Nothing (storedUserToIndexUser user) + storedUserToDoc user = indexUserToDoc defaultSearchVisibilityInbound Nothing Nothing (storedUserToIndexUser user) indexFromStoredUsers :: [StoredUser] -> UserIndex indexFromStoredUsers storedUsers = do diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 86f9f2e9b2..13e54c1785 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -506,7 +506,7 @@ getVerificationCode uid action = runMaybeT do internalSearchIndexAPI :: forall r. (Member UserSubsystem r) => ServerT BrigIRoutes.ISearchIndexAPI (Handler r) internalSearchIndexAPI = Named @"indexRefresh" (NoContent <$ lift (wrapClient Search.refreshIndexes)) - :<|> Named @"update-search-index" (\uid -> lift $ liftSem $ UserSubsystem.internalUpdateSearchIndex uid $> NoContent) + :<|> Named @"update-search-index" (\uid -> lift $ liftSem $ UserSubsystem.internalUpdateSearchIndex uid Nothing $> NoContent) enterpriseLoginApi :: ( Member EnterpriseLoginSubsystem r, @@ -878,7 +878,7 @@ updateSSOIdH uid ssoid = lift $ do liftSem $ if success then do - UserSubsystem.internalUpdateSearchIndex uid + UserSubsystem.internalUpdateSearchIndex uid Nothing Events.generateUserEvent uid Nothing (UserUpdated ((emptyUserUpdatedData uid) {eupSSOId = Just ssoid})) pure UpdateSSOIdSuccess else pure UpdateSSOIdNotFound @@ -894,7 +894,7 @@ deleteSSOIdH uid = lift $ do success <- liftSem $ UserStore.updateSSOId uid Nothing if success then liftSem $ do - UserSubsystem.internalUpdateSearchIndex uid + UserSubsystem.internalUpdateSearchIndex uid Nothing Events.generateUserEvent uid Nothing (UserUpdated ((emptyUserUpdatedData uid) {eupSSOIdRemoved = True})) pure UpdateSSOIdSuccess else pure UpdateSSOIdNotFound diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 10c4a949c2..24eaa9db7e 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -250,7 +250,7 @@ createUserSpar new = do for_ new.newUserSparRichInfo $ UserStore.updateRichInfo uid . unRichInfo GalleyAPIAccess.createSelfConv uid - User.internalUpdateSearchIndex uid + User.internalUpdateSearchIndex uid Nothing Events.generateUserEvent uid Nothing (UserCreated u) -- Add to team @@ -323,7 +323,7 @@ upgradePersonalToTeam luid bNewTeam = do liftSem $ GalleyAPIAccess.changeTeamStatus tid Team.Active bNewTeam.bnuCurrency liftSem $ UserStore.updateUserTeam uid tid - liftSem $ User.internalUpdateSearchIndex uid + liftSem $ User.internalUpdateSearchIndex uid Nothing liftSem $ Intra.sendUserEvent uid Nothing (teamUpdated uid tid) initAccountFeatureConfig uid @@ -720,7 +720,7 @@ changeAccountStatus usrs status = do Sem r () update ev u = do UserStore.updateAccountStatus u status - User.internalUpdateSearchIndex u + User.internalUpdateSearchIndex u Nothing Events.generateUserEvent u Nothing (ev u) changeSingleAccountStatus :: @@ -738,7 +738,7 @@ changeSingleAccountStatus uid status = do ev <- mkUserEvent (NonEmpty.singleton uid) status lift . liftSem $ do UserStore.updateAccountStatus uid status - User.internalUpdateSearchIndex uid + User.internalUpdateSearchIndex uid Nothing Events.generateUserEvent uid Nothing (ev uid) mkUserEvent :: @@ -857,13 +857,13 @@ onActivated (AccountActivated account) = liftSem $ do let uid = userId account Log.debug $ field "user" (toByteString uid) . field "action" (val "User.onActivated") Log.info $ field "user" (toByteString uid) . msg (val "User activated") - User.internalUpdateSearchIndex uid + User.internalUpdateSearchIndex uid Nothing Events.generateUserEvent uid Nothing $ UserActivated account -- userIdentity is always Just at the time of writing this comment, -- since account has been activated already. pure (uid, userIdentity account, True) onActivated (EmailActivated uid email) = liftSem $ do - User.internalUpdateSearchIndex uid + User.internalUpdateSearchIndex uid Nothing Events.generateUserEvent uid Nothing (emailUpdated uid email) UserStore.deleteEmailUnvalidated uid pure (uid, Just (EmailIdentity email), False) @@ -1206,7 +1206,7 @@ deleteAccount user = do Intra.rmUser uid (userAssets user) ClientStore.lookupClients uid >>= mapM_ (ClientStore.delete uid . (.clientId)) luid <- embed $ qualifyLocal uid - User.internalUpdateSearchIndex uid + User.internalUpdateSearchIndex uid Nothing Events.generateUserEvent uid Nothing (UserDeleted (tUntagged luid)) embed do -- Note: Connections can only be deleted afterwards, since From 17d4259254706db234cd8202469fc8d9779d1228 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 24 Aug 2026 16:33:09 +0200 Subject: [PATCH 04/16] Implement TeamCollaboratorsSubsystem interpreter with BrigAPIAccess. Was previously UserSubsystem, but since it TeamCollaboratorsSubsystem is also used outside of Brig, that is not always available. Further changes: - Support BrigAPIAccess locally in Brig. - Change collaborator field type in UserDoc to collapse `Nothing` and `Just []` (remove the Maybe). --- .../src/Wire/AppSubsystem/Interpreter.hs | 4 +- .../wire-subsystems/src/Wire/BrigAPIAccess.hs | 2 +- .../src/Wire/BrigAPIAccess/Local.hs | 38 +++++++++ .../IndexedUserStore/Bulk/ElasticSearch.hs | 22 +++-- .../Wire/IndexedUserStore/ElasticSearch.hs | 8 +- .../src/Wire/TeamCollaboratorsStore.hs | 2 + .../Wire/TeamCollaboratorsStore/Postgres.hs | 17 ++++ .../TeamCollaboratorsSubsystem/Interpreter.hs | 41 +++++----- .../src/Wire/UserSearch/Types.hs | 4 +- .../src/Wire/UserStore/IndexUser.hs | 8 +- .../wire-subsystems/src/Wire/UserSubsystem.hs | 4 +- .../src/Wire/UserSubsystem/Interpreter.hs | 37 ++++++--- .../test/unit/Wire/MiniBackend.hs | 9 +-- .../test/unit/Wire/MockInterpreters.hs | 1 + .../Wire/MockInterpreters/BrigAPIAccess.hs | 80 +++++++++++++++++++ .../TeamCollaboratorsStore.hs | 2 + .../Wire/ScimSubsystem/InterpreterSpec.hs | 2 +- .../test/unit/Wire/UserSearch/TypesSpec.hs | 4 +- .../Wire/UserSubsystem/InterpreterSpec.hs | 2 +- libs/wire-subsystems/wire-subsystems.cabal | 2 + .../background-worker/src/Wire/Effects.hs | 2 +- services/brig/src/Brig/API/Internal.hs | 6 +- services/brig/src/Brig/API/User.hs | 14 ++-- .../brig/src/Brig/CanonicalInterpreter.hs | 20 +++-- services/brig/src/Brig/Index/Eval.hs | 7 ++ services/brig/src/Brig/User/Search/Index.hs | 9 +++ services/galley/src/Galley/App.hs | 2 +- 27 files changed, 269 insertions(+), 80 deletions(-) create mode 100644 libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs create mode 100644 libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs diff --git a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs index 2cac7678e7..e1dd13ff30 100644 --- a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs @@ -129,7 +129,7 @@ createAppImpl lusr tid newApp = do Store.createUser u Nothing now <- toUTCTimeMillis <$> get void $ addTeamMember u.id tid (Just (tUnqualified lusr, now)) R.RoleMember - internalUpdateSearchIndex u.id Nothing + internalUpdateSearchIndex u.id -- generate a team event generateTeamEvents creator.id tid [EdMemberJoin u.id] @@ -214,7 +214,7 @@ updateAppImpl lusr tid appid upd = do Right () -> pure () Left Store.NotFound -> throw AppSubsystemErrorNoApp Store.updateUser appid (def {Store.name = upd.name, Store.assets = upd.assets, Store.accentId = upd.accentId}) - internalUpdateSearchIndex appid Nothing + internalUpdateSearchIndex appid generateUserEvent appid Nothing $ UserUpdated $ (emptyUserUpdatedData appid) diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs index c23b679ed7..72a79fec9c 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs @@ -106,7 +106,7 @@ data BrigAPIAccess m a where GetAccountConferenceCallingConfigClient :: UserId -> BrigAPIAccess m (Feature ConferenceCallingConfig) GetLocalMLSClients :: Local UserId -> CipherSuiteTag -> BrigAPIAccess m (Set ClientInfo) GetLocalMLSClient :: Local UserId -> ClientId -> CipherSuiteTag -> BrigAPIAccess m ClientInfo - UpdateSearchVisibilityInbound :: + UpdateSearchVisibilityInbound :: -- TODO: what's this? do i need to use this instead of UpdateSearchIndex? Multi.TeamStatus SearchVisibilityInboundConfig -> BrigAPIAccess m () GetUserExportData :: UserId -> BrigAPIAccess m (Maybe TeamExportUser) diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs new file mode 100644 index 0000000000..57af12fc3c --- /dev/null +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs @@ -0,0 +1,38 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Interprets 'BrigAPIAccess' from within brig itself, by calling into the local +-- subsystems directly instead of round-tripping over HTTP to itself (as +-- 'Wire.BrigAPIAccess.Rpc.interpretBrigAccess' does for every other service). +-- +-- Only the operations actually needed by code shared with other services (e.g. +-- 'Wire.TeamCollaboratorsSubsystem') are implemented; everything else is +-- unimplemented until brig itself needs it. +module Wire.BrigAPIAccess.Local where + +import Imports +import Polysemy +import Wire.BrigAPIAccess +import Wire.UserSubsystem (UserSubsystem) +import Wire.UserSubsystem qualified as UserSubsystem + +interpretBrigAPIAccessLocally :: + InterpreterFor UserSubsystem r -> + InterpreterFor BrigAPIAccess r +interpretBrigAPIAccessLocally runUser = interpret $ \case + UpdateSearchIndex uid -> runUser (UserSubsystem.internalUpdateSearchIndex uid) + _ -> error "BrigAPIAccess.Local: operation not implemented" -- TODO: shouldn't we make an effort and at least fall back to the Rpc interpreter somehow? diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs index 2142d93aaa..e285ee4ad2 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs @@ -29,6 +29,7 @@ import Data.Conduit.List qualified as CL import Data.Id import Data.Json.Util (UTCTimeMillis (fromUTCTimeMillis)) import Data.Map qualified as Map +import Data.Set qualified as Set import Database.Bloodhound qualified as ES import Imports import Polysemy @@ -37,6 +38,7 @@ import Polysemy.TinyLog import Polysemy.TinyLog qualified as Log import System.Logger.Message qualified as Log import UnliftIO (pooledForConcurrentlyN) +import Wire.API.Team.Collaborator (gTeam, gUser) import Wire.API.Team.Feature import Wire.API.Team.Member.Info import Wire.API.Team.Role @@ -45,6 +47,7 @@ import Wire.IndexedUserStore (IndexedUserStore) import Wire.IndexedUserStore qualified as IndexedUserStore import Wire.IndexedUserStore.MigrationStore import Wire.IndexedUserStore.MigrationStore qualified as MigrationStore +import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore, getTeamCollaborationsForUsers) import Wire.UserSearch.Migration import Wire.UserSearch.Types import Wire.UserStore @@ -54,15 +57,15 @@ type IOInterpreter r = forall a. Sem r a -> IO a -- | Increase this number any time you want to force reindexing. expectedMigrationVersion :: MigrationVersion -expectedMigrationVersion = MigrationVersion 6 +expectedMigrationVersion = MigrationVersion 7 -syncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> IO () +syncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> IO () syncAllUsers interpreter pageSize = syncAllUsersWithVersion interpreter pageSize ES.ExternalGT -forceSyncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> IO () +forceSyncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> IO () forceSyncAllUsers interpreter pageSize = syncAllUsersWithVersion interpreter pageSize ES.ExternalGTE -syncAllUsersWithVersion :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r) => IOInterpreter r -> Int32 -> (ES.ExternalDocVersion -> ES.VersionControl) -> IO () +syncAllUsersWithVersion :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> (ES.ExternalDocVersion -> ES.VersionControl) -> IO () syncAllUsersWithVersion interpreter pageSize mkVersion = runConduit $ zipSources (CL.sourceList [1 ..]) (paginateWithStateC (interpreter . getIndexUsersPaginated pageSize)) @@ -114,6 +117,12 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = fmap Map.unions . pooledForConcurrentlyN 16 (Map.toList teams) $ \(t, us) -> getRoles t (fmap (.userId) us) + -- One query for the whole page. A failure here fails every document of the + -- page, which 'logAndHush' then logs and skips. + eithCollabTeams :: Either SomeException (Map UserId [TeamId]) <- + try . fmap (Map.fromListWith (<>) . map (\tc -> (gUser tc, [gTeam tc]))) . interpreter $ + getTeamCollaborationsForUsers (Set.fromList (map (.userId) page)) + let vis :: IndexUser -> Either SomeException SearchVisibilityInbound vis indexUser = fromMaybe (Right defaultSearchVisibilityInbound) $ flip Map.lookup visMap =<< indexUser.teamId @@ -122,7 +131,8 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = mkUserDoc indexUser = do currentVis <- vis indexUser currentRole <- sequence $ Map.lookup indexUser.userId roles - pure $ indexUserToDoc currentVis ((.value) <$> currentRole) Nothing indexUser + currentCollabTeams <- Map.findWithDefault [] indexUser.userId <$> eithCollabTeams + pure $ indexUserToDoc currentVis ((.value) <$> currentRole) currentCollabTeams indexUser mkDocVersion :: IndexUser -> Either SomeException ES.VersionControl mkDocVersion u = do @@ -159,7 +169,7 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = <$> permissionsToRole tmi.permissions migrateData :: - (Member (Embed IO) r, Member IndexedUserStore r, Member (Error MigrationException) r, Member IndexedUserMigrationStore r, Member TinyLog r, Member UserStore r, Member GalleyAPIAccess r) => + (Member (Embed IO) r, Member IndexedUserStore r, Member (Error MigrationException) r, Member IndexedUserMigrationStore r, Member TinyLog r, Member UserStore r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> IO () diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index ec3fc34aeb..07572a2985 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -526,13 +526,17 @@ matchSelf :: UserId -> Maybe ES.Query matchSelf searcher = Just (termQ "_id" (idToText searcher)) -- | Exclude apps from other teams. --- Apps should only be searchable within their own team. +-- Apps should only be searchable within their own team, or within a team they +-- collaborate with. matchAppsFromOtherTeams :: Maybe TeamId -> Maybe ES.Query matchAppsFromOtherTeams mSearcherTeamId = Just $ ES.QueryBoolQuery boolQuery - { ES.boolQueryMustMatch = + { -- Apps collaborating with the searcher's team are not excluded. + ES.boolQueryMustNotMatch = + maybeToList (termQ "collaborating_teams" . idToText <$> mSearcherTeamId), + ES.boolQueryMustMatch = [ -- Match apps (type = "app") termQ "type" "app", -- That are from a different team than the searcher diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs index fcdf0731b0..ebf79c9672 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore.hs @@ -29,6 +29,8 @@ data TeamCollaboratorsStore m a where GetAllTeamCollaborators :: TeamId -> TeamCollaboratorsStore m [TeamCollaborator] GetTeamCollaborator :: TeamId -> UserId -> TeamCollaboratorsStore m (Maybe TeamCollaborator) GetTeamCollaborations :: UserId -> TeamCollaboratorsStore m ([TeamCollaborator]) + -- | Batched 'GetTeamCollaborations', for callers that process users in pages. + GetTeamCollaborationsForUsers :: Set UserId -> TeamCollaboratorsStore m [TeamCollaborator] GetTeamCollaboratorsWithIds :: Set TeamId -> Set UserId -> TeamCollaboratorsStore m [TeamCollaborator] UpdateTeamCollaborator :: UserId -> TeamId -> Set CollaboratorPermission -> TeamCollaboratorsStore m () RemoveTeamCollaborator :: UserId -> TeamId -> TeamCollaboratorsStore m () diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs index a6a1e968a7..b898ae69b5 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs @@ -49,6 +49,7 @@ interpretTeamCollaboratorsStoreToPostgres = GetAllTeamCollaborators teamId -> getAllTeamCollaboratorsImpl teamId GetTeamCollaborator teamId userId -> getTeamCollaboratorImpl teamId userId GetTeamCollaborations userId -> getTeamCollaborationsImpl userId + GetTeamCollaborationsForUsers userIds -> getTeamCollaborationsForUsersImpl userIds GetTeamCollaboratorsWithIds teamIds userIds -> getTeamCollaboratorsWithIdsImpl teamIds userIds UpdateTeamCollaborator userId teamId permissions -> updateTeamCollaboratorImpl userId teamId permissions RemoveTeamCollaborator userId teamId -> removeTeamCollaboratorImpl userId teamId @@ -181,6 +182,22 @@ getTeamCollaborationsImpl teamId = do select user_id :: uuid, team_id :: uuid, permissions :: int2[] from collaborators where user_id = ($1 :: uuid) |] +getTeamCollaborationsForUsersImpl :: + (PGConstraints r) => + Set UserId -> + Sem r [TeamCollaborator] +getTeamCollaborationsForUsersImpl userIds = do + runStatement (Data.Set.toList userIds) getAllCollaborationsByUsersStatement + where + getAllCollaborationsByUsersStatement :: Statement [UserId] [TeamCollaborator] + getAllCollaborationsByUsersStatement = + dimap + (Data.Vector.fromList . Imports.map toUUID) + (Data.Vector.toList . (toTeamCollaborator <$>)) + $ [vectorStatement| + select user_id :: uuid, team_id :: uuid, permissions :: int2[] from collaborators where user_id = ANY($1 :: uuid[]) + |] + getTeamCollaboratorsWithIdsImpl :: (PGConstraints r) => Set TeamId -> diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs index af094594ab..ab2d6360b4 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs @@ -29,6 +29,8 @@ import Wire.API.Error.Brig qualified as E import Wire.API.Event.Team import Wire.API.Team.Collaborator import Wire.API.Team.Member qualified as TeamMember +import Wire.BrigAPIAccess (BrigAPIAccess) +import Wire.BrigAPIAccess qualified as BrigAPIAccess import Wire.Error import Wire.NotificationSubsystem import Wire.Sem.Now @@ -36,26 +38,26 @@ import Wire.TeamCollaboratorsStore qualified as Store import Wire.TeamCollaboratorsSubsystem import Wire.TeamSubsystem import Wire.TeamSubsystem.Util -import Wire.UserSubsystem (UserSubsystem) -import Wire.UserSubsystem qualified as UserSubsystem interpretTeamCollaboratorsSubsystem :: ( Member TeamSubsystem r, Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, - Member NotificationSubsystem r, - Member UserSubsystem r + Member NotificationSubsystem r ) => + InterpreterFor BrigAPIAccess r -> InterpreterFor TeamCollaboratorsSubsystem r -interpretTeamCollaboratorsSubsystem = interpret $ \case - CreateTeamCollaborator zUser user team perms -> createTeamCollaboratorImpl zUser user team perms - GetAllTeamCollaborators zUser team -> getAllTeamCollaboratorsImpl zUser team - InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user - InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId - InternalGetTeamCollaboratorsWithIds teams userIds -> internalGetTeamCollaboratorsWithIdsImpl teams userIds - InternalUpdateTeamCollaborator user team perms -> internalUpdateTeamCollaboratorImpl user team perms - InternalRemoveTeamCollaborator user team -> internalRemoveTeamCollaboratorImpl user team +interpretTeamCollaboratorsSubsystem brigAPIAccess = + interpret $ + brigAPIAccess . \case + CreateTeamCollaborator zUser user team perms -> createTeamCollaboratorImpl zUser user team perms + GetAllTeamCollaborators zUser team -> getAllTeamCollaboratorsImpl zUser team + InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user + InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId + InternalGetTeamCollaboratorsWithIds teams userIds -> internalGetTeamCollaboratorsWithIdsImpl teams userIds + InternalUpdateTeamCollaborator user team perms -> internalUpdateTeamCollaboratorImpl user team perms + InternalRemoveTeamCollaborator user team -> internalRemoveTeamCollaboratorImpl user team internalGetTeamCollaboratorImpl :: (Member Store.TeamCollaboratorsStore r) => @@ -78,7 +80,7 @@ createTeamCollaboratorImpl :: Member Store.TeamCollaboratorsStore r, Member Now r, Member NotificationSubsystem r, - Member UserSubsystem r + Member BrigAPIAccess r ) => Local UserId -> UserId -> @@ -93,8 +95,7 @@ createTeamCollaboratorImpl zUser user team perms = do generateTeamEvents (tUnqualified zUser) team [EdCollaboratorAdd user (Set.toList perms)] -- Reindex the collaborator with their new collaboration team - collaborations <- Store.getTeamCollaborations user - UserSubsystem.internalUpdateSearchIndex user (Just (map gTeam collaborations)) + BrigAPIAccess.updateSearchIndex user getAllTeamCollaboratorsImpl :: ( Member TeamSubsystem r, @@ -117,7 +118,7 @@ internalGetTeamCollaboratorsWithIdsImpl = do Store.getTeamCollaboratorsWithIds internalUpdateTeamCollaboratorImpl :: - (Member Store.TeamCollaboratorsStore r, Member UserSubsystem r) => + (Member Store.TeamCollaboratorsStore r, Member BrigAPIAccess r) => UserId -> TeamId -> Set CollaboratorPermission -> @@ -125,19 +126,17 @@ internalUpdateTeamCollaboratorImpl :: internalUpdateTeamCollaboratorImpl user team perms = do Store.updateTeamCollaborator user team perms -- Reindex collaborator when permissions change - collaborations <- Store.getTeamCollaborations user - UserSubsystem.internalUpdateSearchIndex user (Just (map gTeam collaborations)) + BrigAPIAccess.updateSearchIndex user internalRemoveTeamCollaboratorImpl :: - (Member Store.TeamCollaboratorsStore r, Member UserSubsystem r) => + (Member Store.TeamCollaboratorsStore r, Member BrigAPIAccess r) => UserId -> TeamId -> Sem r () internalRemoveTeamCollaboratorImpl user team = do Store.removeTeamCollaborator user team -- Reindex collaborator when removed - collaborations <- Store.getTeamCollaborations user - UserSubsystem.internalUpdateSearchIndex user (Just (map gTeam collaborations)) + BrigAPIAccess.updateSearchIndex user -- This is of general usefulness. However, we cannot move this to wire-api as -- this would lead to a cyclic dependency. diff --git a/libs/wire-subsystems/src/Wire/UserSearch/Types.hs b/libs/wire-subsystems/src/Wire/UserSearch/Types.hs index 58ab0de81e..5464dae2a8 100644 --- a/libs/wire-subsystems/src/Wire/UserSearch/Types.hs +++ b/libs/wire-subsystems/src/Wire/UserSearch/Types.hs @@ -82,7 +82,7 @@ data UserDoc = UserDoc udSearchable :: Maybe Bool, -- | Teams that have added this user as a collaborator. -- Updated separately via 'syncUserIndexCollaborations' when collaborator relationships change. - udCollaboratingTeams :: Maybe [TeamId] + udCollaboratingTeams :: [TeamId] } deriving (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserDoc) @@ -132,7 +132,7 @@ instance FromJSON UserDoc where <*> o .:? "sso" <*> o .:? "email_unvalidated" <*> o .:? "searchable" - <*> o .:? "collaborating_teams" + <*> o .:? "collaborating_teams" .!= [] searchVisibilityInboundFieldName :: Key searchVisibilityInboundFieldName = "search_visibility_inbound" diff --git a/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs b/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs index 1d54a2da1c..b051132f1e 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/IndexUser.hs @@ -126,8 +126,8 @@ indexUserToVersion :: Maybe (WithWritetime Role) -> IndexUser -> IndexVersion indexUserToVersion role iu = mkIndexVersion [Just $ Writetime iu.updatedAt, const () <$$> fmap writetime role] -indexUserToDoc :: SearchVisibilityInbound -> Maybe Role -> Maybe [TeamId] -> IndexUser -> UserDoc -indexUserToDoc searchVisInbound mRole mCollaboratingTeams IndexUser {..} = +indexUserToDoc :: SearchVisibilityInbound -> Maybe Role -> [TeamId] -> IndexUser -> UserDoc +indexUserToDoc searchVisInbound mRole collaboratingTeams IndexUser {..} = if shouldIndex then UserDoc @@ -149,7 +149,7 @@ indexUserToDoc searchVisInbound mRole mCollaboratingTeams IndexUser {..} = udNormalized = Just $ normalized name.fromName, udName = Just name, udTeam = teamId, - udCollaboratingTeams = mCollaboratingTeams + udCollaboratingTeams = collaboratingTeams } else -- We insert a tombstone-style user here, as it's easier than -- deleting the old one. It's mostly empty, but having the status here @@ -210,6 +210,6 @@ emptyUserDoc uid = udNormalized = Nothing, udName = Nothing, udTeam = Nothing, - udCollaboratingTeams = Nothing, + udCollaboratingTeams = [], udId = uid } diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem.hs b/libs/wire-subsystems/src/Wire/UserSubsystem.hs index 5988c58573..0f5f428ae7 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem.hs @@ -180,7 +180,7 @@ data UserSubsystem m a where AcceptTeamInvitation :: Local UserId -> PlainTextPassword6 -> InvitationCode -> UserSubsystem m () -- | The following "internal" functions exists to support migration in this susbystem, after the -- migration this would just be an internal detail of the subsystem - InternalUpdateSearchIndex :: UserId -> Maybe [TeamId] -> UserSubsystem m () + InternalUpdateSearchIndex :: UserId -> UserSubsystem m () InternalFindTeamInvitation :: Maybe EmailKey -> InvitationCode -> UserSubsystem m StoredInvitation GetUserExportData :: UserId -> UserSubsystem m (Maybe TeamExportUser) RemoveEmailEither :: Local UserId -> UserSubsystem m (Either UserSubsystemError ()) @@ -272,7 +272,7 @@ requestEmailChange lusr email allowScim = do ChangeEmailNeedsActivation (usr, adata, en) -> do sendOutEmail usr adata en updateEmailUnvalidated u email - internalUpdateSearchIndex u Nothing + internalUpdateSearchIndex u pure ChangeEmailResponseNeedsActivation where throwGuardFailed :: diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index d766491d85..f78029cebd 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -60,6 +60,7 @@ import Wire.API.Federation.Error import Wire.API.MLS.CipherSuite (CipherSuiteTag, csSignatureScheme) import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus (..)) +import Wire.API.Team.Collaborator (gTeam) import Wire.API.Team.Export import Wire.API.Team.Feature import Wire.API.Team.Member @@ -102,6 +103,8 @@ import Wire.Sem.Metrics qualified as Metrics import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.StoredUser +import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore) +import Wire.TeamCollaboratorsStore qualified as TeamCollaboratorsStore import Wire.TeamSubsystem import Wire.UserGroupStore (UserGroupStore, getUserGroupIdsForUsers) import Wire.UserKeyStore @@ -141,6 +144,7 @@ runUserSubsystem :: Member TinyLog r, Member (Input UserSubsystemConfig) r, Member TeamSubsystem r, + Member TeamCollaboratorsStore r, Member UserGroupStore r, Member (Input (Local any)) r ) => @@ -194,8 +198,8 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = isUsersContactableImpl users mlsAvailable allowedCipherSuites BrowseTeam uid browseTeamFilters mMaxResults mPagingState -> browseTeamImpl uid browseTeamFilters mMaxResults mPagingState - InternalUpdateSearchIndex uid mTeams -> - syncUserIndex uid mTeams + InternalUpdateSearchIndex uid -> + syncUserIndex uid AcceptTeamInvitation luid pwd code -> acceptTeamInvitationImpl luid pwd code InternalFindTeamInvitation mEmailKey code -> @@ -711,6 +715,7 @@ updateUserProfileImpl :: Member Events r, Member GalleyAPIAccess r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> @@ -725,7 +730,7 @@ updateUserProfileImpl (tUnqualified -> uid) mconn updateOrigin update = do mapError (\StoredUserUpdateHandleExists -> UserSubsystemHandleExists) $ updateUser uid (storedUserUpdate update) let interestingToUpdateIndex = isJust update.name || isJust update.accentId - when interestingToUpdateIndex $ syncUserIndex uid Nothing + when interestingToUpdateIndex $ syncUserIndex uid generateUserEvent uid mconn (mkProfileUpdateEvent uid update) where guardMlsSupport user = for_ update.supportedProtocols $ \protocols -> do @@ -772,6 +777,7 @@ updateHandleImpl :: Member Events r, Member UserStore r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> @@ -789,7 +795,7 @@ updateHandleImpl (tUnqualified -> uid) mconn updateOrigin uhandle = do throw UserSubsystemNoIdentity mapError (\StoredUserUpdateHandleExists -> UserSubsystemHandleExists) $ UserStore.updateUserHandle uid (MkStoredUserHandleUpdate user.handle newHandle) - syncUserIndex uid Nothing + syncUserIndex uid generateUserEvent uid mconn (mkProfileUpdateHandleEvent uid newHandle) checkHandleImpl :: (Member (Error UserSubsystemError) r, Member UserStore r) => Text -> Sem r CheckHandleResp @@ -839,12 +845,12 @@ syncUserIndex :: ( Member UserStore r, Member GalleyAPIAccess r, Member IndexedUserStore r, - Member Metrics r + Member Metrics r, + Member TeamCollaboratorsStore r ) => UserId -> - Maybe [TeamId] -> Sem r () -syncUserIndex uid mCollabTeams = +syncUserIndex uid = getIndexUser uid >>= maybe deleteFromIndex upsert where @@ -861,9 +867,13 @@ syncUserIndex uid mCollabTeams = teamSearchVisibilityInbound indexUser.teamId tm <- maybe (pure Nothing) selectTeamMember indexUser.teamId + collabTeams <- map gTeam <$> TeamCollaboratorsStore.getTeamCollaborations uid let mRole = tm >>= mkRoleWithWriteTime - userDoc = indexUserToDoc vis (value <$> mRole) mCollabTeams indexUser - version = ES.ExternalGT . ES.ExternalDocVersion . docVersion $ indexUserToVersion mRole indexUser + userDoc = indexUserToDoc vis (value <$> mRole) collabTeams indexUser + -- GTE, not GT: the version comes from the user row alone, but the document also + -- holds data that changes without touching that row (collaborations), and under + -- GT those updates would be dropped as version conflicts. Older writes still lose. + version = ES.ExternalGTE . ES.ExternalDocVersion . docVersion $ indexUserToVersion mRole indexUser Metrics.incCounter indexUpdateCounter IndexedUserStore.upsert (userIdToDocId uid) userDoc version @@ -1181,6 +1191,7 @@ acceptTeamInvitationImpl :: Member (Error UserSubsystemError) r, Member InvitationStore r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member Metrics r, Member Events r, Member AuthenticationSubsystem r, @@ -1209,7 +1220,7 @@ acceptTeamInvitationImpl luid pw code = do deleteInvitation inv.teamId inv.invitationId for_ (userEmail . selfUser =<< mSelfProfile) $ \email -> deletePendingScimUser tid email uid - syncUserIndex uid Nothing + syncUserIndex uid generateUserEvent uid Nothing (teamUpdated uid tid) getUserExportDataImpl :: (Member UserStore r, Member ClientSubsystem r) => UserId -> Sem r (Maybe TeamExportUser) @@ -1245,6 +1256,7 @@ removeEmailEitherImpl :: Member UserStore r, Member Events r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member (Input UserSubsystemConfig) r, Member GalleyAPIAccess r, Member Metrics r @@ -1259,7 +1271,7 @@ removeEmailEitherImpl lusr = runError $ do deleteKey $ mkEmailKey e deleteEmail uid generateUserEvent uid Nothing (emailRemoved uid e) - syncUserIndex uid Nothing + syncUserIndex uid Just _ -> throw UserSubsystemLastIdentity Nothing -> throw UserSubsystemNoIdentity @@ -1281,6 +1293,7 @@ setUserSearchableImpl :: Member TeamSubsystem r, Member GalleyAPIAccess r, Member IndexedUserStore r, + Member TeamCollaboratorsStore r, Member Metrics r ) => Local UserId -> @@ -1291,4 +1304,4 @@ setUserSearchableImpl luid uid searchable = do tid <- maybe (throw UserSubsystemInsufficientPermissions) pure =<< UserStore.getUserTeam uid ensurePermissions (tUnqualified luid) tid [SetMemberSearchable] UserStore.setUserSearchable uid searchable - syncUserIndex uid Nothing + syncUserIndex uid diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 1324d919db..ff27a27fa7 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -365,22 +365,21 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . runInputConst conversationCfg . runClientSubsystem undefined undefined where - -- Mock BrigAPIAccess interpreter for tests - mockBrigAPIAccess :: forall r'. InterpreterFor BrigAPIAccess r' - mockBrigAPIAccess = interpret $ \case - _ -> error "Unimplemented BrigAPIAccess operation in mock" -- Mock UserClientIndexStore interpreter for tests mockUserClientIndexStore :: forall r'. InterpreterFor UserClientIndexStore r' mockUserClientIndexStore = interpret $ \case _ -> error "Unimplemented UserClientIndexStore operation in mock" + -- Mock BackendNotificationQueueAccess interpreter for tests mockBackendNotificationQueueAccess :: forall r'. InterpreterFor BackendNotificationQueueAccess r' mockBackendNotificationQueueAccess = interpret $ \case _ -> error "Unimplemented BackendNotificationQueueAccess operation in mock" + -- Mock ConversationSubsystem interpreter for tests mockConversationSubsystem :: forall r'. InterpreterFor ConversationSubsystem r' mockConversationSubsystem = interpretH $ \case _ -> error "Unimplemented ConversationSubsystem operation in mock" + mockMlsKeyPackageSubsystem :: forall r'. InterpreterFor MlsKeyPackageSubsystem r' mockMlsKeyPackageSubsystem = interpret $ \case HasMlsKeyPackages {} -> pure False @@ -786,7 +785,7 @@ interpretMaybeFederationStackState :: Sem (MiniBackendEffects `Append` r) a -> Sem r (MiniBackend, a) interpretMaybeFederationStackState mb = - miniBackendLowerEffectsInterpreters mb . interpretTeamCollaboratorsSubsystem . runRecursiveAuthUserApp + miniBackendLowerEffectsInterpreters mb . interpretTeamCollaboratorsSubsystem subsume . runRecursiveAuthUserApp -- FUTUREWORK(fisx): it would be nice to have a definition of an -- interpreter of all the subsystems combined, but since the diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs index 4630c0c7f7..ea57140aad 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs @@ -25,6 +25,7 @@ import Wire.MockInterpreters.AppStore as MockInterpreters import Wire.MockInterpreters.AuthenticationSubsystem as MockInterpreters import Wire.MockInterpreters.BackgroundJobPublisher as MockInterpreters import Wire.MockInterpreters.BlockListStore as MockInterpreters +import Wire.MockInterpreters.BrigAPIAccess as MockInterpreters import Wire.MockInterpreters.ClientStore as MockInterpreters import Wire.MockInterpreters.ConversationStore as MockInterpreters import Wire.MockInterpreters.ConversationSubsystem as MockInterpreters diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs new file mode 100644 index 0000000000..5aacaddac9 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs @@ -0,0 +1,80 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MockInterpreters.BrigAPIAccess where + +import Imports +import Polysemy +import Wire.BrigAPIAccess + +-- | Errors out on everything except 'UpdateSearchIndex', which is a no-op. +mockBrigAPIAccess :: InterpreterFor BrigAPIAccess r +mockBrigAPIAccess = interpret $ \case + GetConnectionsUnqualified {} -> error "GetConnectionsUnqualified: implement on demand (mockBrigAPIAccess)" + GetConnections {} -> error "GetConnections: implement on demand (mockBrigAPIAccess)" + PutConnectionInternal {} -> error "PutConnectionInternal: implement on demand (mockBrigAPIAccess)" + ReauthUser {} -> error "ReauthUser: implement on demand (mockBrigAPIAccess)" + LookupActivatedUsers {} -> error "LookupActivatedUsers: implement on demand (mockBrigAPIAccess)" + GetUsers {} -> error "GetUsers: implement on demand (mockBrigAPIAccess)" + DeleteUser {} -> error "DeleteUser: implement on demand (mockBrigAPIAccess)" + GetContactList {} -> error "GetContactList: implement on demand (mockBrigAPIAccess)" + GetSize {} -> error "GetSize: implement on demand (mockBrigAPIAccess)" + LookupClients {} -> error "LookupClients: implement on demand (mockBrigAPIAccess)" + LookupClientsFull {} -> error "LookupClientsFull: implement on demand (mockBrigAPIAccess)" + NotifyClientsAboutLegalHoldRequest {} -> error "NotifyClientsAboutLegalHoldRequest: implement on demand (mockBrigAPIAccess)" + GetLegalHoldAuthToken {} -> error "GetLegalHoldAuthToken: implement on demand (mockBrigAPIAccess)" + AddLegalHoldClientToUserEither {} -> error "AddLegalHoldClientToUserEither: implement on demand (mockBrigAPIAccess)" + RemoveLegalHoldClientFromUser {} -> error "RemoveLegalHoldClientFromUser: implement on demand (mockBrigAPIAccess)" + GetAccountConferenceCallingConfigClient {} -> error "GetAccountConferenceCallingConfigClient: implement on demand (mockBrigAPIAccess)" + GetLocalMLSClients {} -> error "GetLocalMLSClients: implement on demand (mockBrigAPIAccess)" + GetLocalMLSClient {} -> error "GetLocalMLSClient: implement on demand (mockBrigAPIAccess)" + UpdateSearchVisibilityInbound {} -> error "UpdateSearchVisibilityInbound: implement on demand (mockBrigAPIAccess)" + GetUserExportData {} -> error "GetUserExportData: implement on demand (mockBrigAPIAccess)" + DeleteBot {} -> error "DeleteBot: implement on demand (mockBrigAPIAccess)" + UpdateSearchIndex _ -> pure () + GetAccountsBy {} -> error "GetAccountsBy: implement on demand (mockBrigAPIAccess)" + GetUsersByVariousKeys {} -> error "GetUsersByVariousKeys: implement on demand (mockBrigAPIAccess)" + CreateGroupInternal {} -> error "CreateGroupInternal: implement on demand (mockBrigAPIAccess)" + GetGroupInternal {} -> error "GetGroupInternal: implement on demand (mockBrigAPIAccess)" + GetGroupsInternal {} -> error "GetGroupsInternal: implement on demand (mockBrigAPIAccess)" + UpdateGroup {} -> error "UpdateGroup: implement on demand (mockBrigAPIAccess)" + DeleteGroupInternal {} -> error "DeleteGroupInternal: implement on demand (mockBrigAPIAccess)" + DeleteApp {} -> error "DeleteApp: implement on demand (mockBrigAPIAccess)" + GetAppIdsForTeam {} -> error "GetAppIdsForTeam: implement on demand (mockBrigAPIAccess)" + SetAccountStatus {} -> error "SetAccountStatus: implement on demand (mockBrigAPIAccess)" + CreateSAML {} -> error "CreateSAML: implement on demand (mockBrigAPIAccess)" + CreateNoSAML {} -> error "CreateNoSAML: implement on demand (mockBrigAPIAccess)" + UpdateEmail {} -> error "UpdateEmail: implement on demand (mockBrigAPIAccess)" + GetAccount {} -> error "GetAccount: implement on demand (mockBrigAPIAccess)" + GetAccountByHandle {} -> error "GetAccountByHandle: implement on demand (mockBrigAPIAccess)" + GetByEmail {} -> error "GetByEmail: implement on demand (mockBrigAPIAccess)" + SetName {} -> error "SetName: implement on demand (mockBrigAPIAccess)" + SetHandle {} -> error "SetHandle: implement on demand (mockBrigAPIAccess)" + SetManagedBy {} -> error "SetManagedBy: implement on demand (mockBrigAPIAccess)" + DeletePendingEmailUpdate {} -> error "DeletePendingEmailUpdate: implement on demand (mockBrigAPIAccess)" + SetSSOId {} -> error "SetSSOId: implement on demand (mockBrigAPIAccess)" + SetRichInfo {} -> error "SetRichInfo: implement on demand (mockBrigAPIAccess)" + SetLocale {} -> error "SetLocale: implement on demand (mockBrigAPIAccess)" + GetRichInfo {} -> error "GetRichInfo: implement on demand (mockBrigAPIAccess)" + CheckHandleAvailable {} -> error "CheckHandleAvailable: implement on demand (mockBrigAPIAccess)" + SsoLogin {} -> error "SsoLogin: implement on demand (mockBrigAPIAccess)" + GetStatus {} -> error "GetStatus: implement on demand (mockBrigAPIAccess)" + GetStatusMaybe {} -> error "GetStatusMaybe: implement on demand (mockBrigAPIAccess)" + SetStatus {} -> error "SetStatus: implement on demand (mockBrigAPIAccess)" + GetDefaultUserLocale {} -> error "GetDefaultUserLocale: implement on demand (mockBrigAPIAccess)" + CheckAdminGetTeamId {} -> error "CheckAdminGetTeamId: implement on demand (mockBrigAPIAccess)" + SendSAMLIdPChangedEmail {} -> error "SendSAMLIdPChangedEmail: implement on demand (mockBrigAPIAccess)" diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs index 4def51eeef..63a334527a 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/TeamCollaboratorsStore.hs @@ -41,6 +41,8 @@ inMemoryTeamCollaboratorsStoreInterpreter = gets $ \(s :: Map TeamId [TeamCollaborator]) -> find (\tc -> tc.gUser == userId) =<< Map.lookup teamId s GetTeamCollaborations userId -> gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (filter (\tc -> tc.gUser == userId)) (Map.elems s) + GetTeamCollaborationsForUsers userIds -> + gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (filter (\tc -> tc.gUser `elem` userIds)) (Map.elems s) GetTeamCollaboratorsWithIds teamIds userIds -> gets $ \(s :: Map TeamId [TeamCollaborator]) -> concatMap (concatMap (filter (\tc -> tc.gUser `elem` userIds)) . (\(tid :: TeamId) -> Map.lookup tid s)) teamIds diff --git a/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs index 6861f09779..f623e0012d 100644 --- a/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ScimSubsystem/InterpreterSpec.hs @@ -42,7 +42,7 @@ import Wire.API.User as User import Wire.API.User.Scim import Wire.API.UserGroup import Wire.BrigAPIAccess (BrigAPIAccess (..)) -import Wire.MockInterpreters +import Wire.MockInterpreters hiding (mockBrigAPIAccess) import Wire.ScimSubsystem import Wire.ScimSubsystem.Interpreter import Wire.StoredUser diff --git a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs index 413c09d0bd..0a18b9d62f 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs @@ -50,7 +50,7 @@ userDoc1 = UserDoc { udId = fromJust . hush . parseIdFromText $ "0a96b396-57d6-11ea-a04b-7b93d1a5c19c", udTeam = hush . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", - udCollaboratingTeams = fmap (: []) . hush . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", + udCollaboratingTeams = either (error . show) (: []) . parseIdFromText $ "17c59b18-57d6-11ea-9220-8bbf5eee961a", udName = Just . Name $ "Carl Phoomp", udNormalized = Just $ "carl phoomp", udHandle = Just . fromJust . parseHandle $ "phoompy", @@ -71,4 +71,4 @@ userDoc1 = -- Dont touch this. This represents serialized legacy data. userDoc1ByteString :: LByteString -userDoc1ByteString = "{\"email\":\"phoompy@example.com\",\"account_status\":\"active\",\"handle\":\"phoompy\",\"managed_by\":\"scim\",\"role\":\"admin\",\"accent_id\":32,\"name\":\"Carl Phoomp\",\"created_at\":\"2020-08-29T21:50:00.000Z\",\"team\":\"17c59b18-57d6-11ea-9220-8bbf5eee961a\",\"id\":\"0a96b396-57d6-11ea-a04b-7b93d1a5c19c\",\"normalized\":\"carl phoomp\",\"saml_idp\":\"https://issuer.net/214234\"}" +userDoc1ByteString = "{\"collaborating_teams\":[\"17c59b18-57d6-11ea-9220-8bbf5eee961a\"],\"email\":\"phoompy@example.com\",\"account_status\":\"active\",\"handle\":\"phoompy\",\"managed_by\":\"scim\",\"role\":\"admin\",\"accent_id\":32,\"name\":\"Carl Phoomp\",\"created_at\":\"2020-08-29T21:50:00.000Z\",\"team\":\"17c59b18-57d6-11ea-9220-8bbf5eee961a\",\"id\":\"0a96b396-57d6-11ea-a04b-7b93d1a5c19c\",\"normalized\":\"carl phoomp\",\"saml_idp\":\"https://issuer.net/214234\"}" diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index faca707880..9e309d50dd 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -1110,7 +1110,7 @@ spec = describe "UserSubsystem.Interpreter" do searchee = searcheeNoHandle {handle = Just searcheeHandle} :: StoredUser storedUserToDoc :: StoredUser -> UserDoc - storedUserToDoc user = indexUserToDoc defaultSearchVisibilityInbound Nothing Nothing (storedUserToIndexUser user) + storedUserToDoc user = indexUserToDoc defaultSearchVisibilityInbound Nothing [] (storedUserToIndexUser user) indexFromStoredUsers :: [StoredUser] -> UserIndex indexFromStoredUsers storedUsers = do diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index f7627036f1..42196d6e18 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -244,6 +244,7 @@ library Wire.BoundedQueue Wire.BoundedQueue.STM Wire.BrigAPIAccess + Wire.BrigAPIAccess.Local Wire.BrigAPIAccess.Rpc Wire.BudgetStore Wire.BudgetStore.Cassandra @@ -650,6 +651,7 @@ test-suite wire-subsystems-tests Wire.MockInterpreters.AuthenticationSubsystem Wire.MockInterpreters.BackgroundJobPublisher Wire.MockInterpreters.BlockListStore + Wire.MockInterpreters.BrigAPIAccess Wire.MockInterpreters.ClientStore Wire.MockInterpreters.ConversationStore Wire.MockInterpreters.ConversationSubsystem diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 0d367f19ee..66dbee09c5 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -367,7 +367,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = ) . runFeaturesConfigSubsystem . runInputSem getAllTeamFeaturesForServer - . interpretTeamCollaboratorsSubsystem + . interpretTeamCollaboratorsSubsystem (interpretBrigAccess env.brigEndpoint) . discardMeetingNotifier . interpretConversationSubsystem where diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 13e54c1785..86f9f2e9b2 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -506,7 +506,7 @@ getVerificationCode uid action = runMaybeT do internalSearchIndexAPI :: forall r. (Member UserSubsystem r) => ServerT BrigIRoutes.ISearchIndexAPI (Handler r) internalSearchIndexAPI = Named @"indexRefresh" (NoContent <$ lift (wrapClient Search.refreshIndexes)) - :<|> Named @"update-search-index" (\uid -> lift $ liftSem $ UserSubsystem.internalUpdateSearchIndex uid Nothing $> NoContent) + :<|> Named @"update-search-index" (\uid -> lift $ liftSem $ UserSubsystem.internalUpdateSearchIndex uid $> NoContent) enterpriseLoginApi :: ( Member EnterpriseLoginSubsystem r, @@ -878,7 +878,7 @@ updateSSOIdH uid ssoid = lift $ do liftSem $ if success then do - UserSubsystem.internalUpdateSearchIndex uid Nothing + UserSubsystem.internalUpdateSearchIndex uid Events.generateUserEvent uid Nothing (UserUpdated ((emptyUserUpdatedData uid) {eupSSOId = Just ssoid})) pure UpdateSSOIdSuccess else pure UpdateSSOIdNotFound @@ -894,7 +894,7 @@ deleteSSOIdH uid = lift $ do success <- liftSem $ UserStore.updateSSOId uid Nothing if success then liftSem $ do - UserSubsystem.internalUpdateSearchIndex uid Nothing + UserSubsystem.internalUpdateSearchIndex uid Events.generateUserEvent uid Nothing (UserUpdated ((emptyUserUpdatedData uid) {eupSSOIdRemoved = True})) pure UpdateSSOIdSuccess else pure UpdateSSOIdNotFound diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 24eaa9db7e..10c4a949c2 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -250,7 +250,7 @@ createUserSpar new = do for_ new.newUserSparRichInfo $ UserStore.updateRichInfo uid . unRichInfo GalleyAPIAccess.createSelfConv uid - User.internalUpdateSearchIndex uid Nothing + User.internalUpdateSearchIndex uid Events.generateUserEvent uid Nothing (UserCreated u) -- Add to team @@ -323,7 +323,7 @@ upgradePersonalToTeam luid bNewTeam = do liftSem $ GalleyAPIAccess.changeTeamStatus tid Team.Active bNewTeam.bnuCurrency liftSem $ UserStore.updateUserTeam uid tid - liftSem $ User.internalUpdateSearchIndex uid Nothing + liftSem $ User.internalUpdateSearchIndex uid liftSem $ Intra.sendUserEvent uid Nothing (teamUpdated uid tid) initAccountFeatureConfig uid @@ -720,7 +720,7 @@ changeAccountStatus usrs status = do Sem r () update ev u = do UserStore.updateAccountStatus u status - User.internalUpdateSearchIndex u Nothing + User.internalUpdateSearchIndex u Events.generateUserEvent u Nothing (ev u) changeSingleAccountStatus :: @@ -738,7 +738,7 @@ changeSingleAccountStatus uid status = do ev <- mkUserEvent (NonEmpty.singleton uid) status lift . liftSem $ do UserStore.updateAccountStatus uid status - User.internalUpdateSearchIndex uid Nothing + User.internalUpdateSearchIndex uid Events.generateUserEvent uid Nothing (ev uid) mkUserEvent :: @@ -857,13 +857,13 @@ onActivated (AccountActivated account) = liftSem $ do let uid = userId account Log.debug $ field "user" (toByteString uid) . field "action" (val "User.onActivated") Log.info $ field "user" (toByteString uid) . msg (val "User activated") - User.internalUpdateSearchIndex uid Nothing + User.internalUpdateSearchIndex uid Events.generateUserEvent uid Nothing $ UserActivated account -- userIdentity is always Just at the time of writing this comment, -- since account has been activated already. pure (uid, userIdentity account, True) onActivated (EmailActivated uid email) = liftSem $ do - User.internalUpdateSearchIndex uid Nothing + User.internalUpdateSearchIndex uid Events.generateUserEvent uid Nothing (emailUpdated uid email) UserStore.deleteEmailUnvalidated uid pure (uid, Just (EmailIdentity email), False) @@ -1206,7 +1206,7 @@ deleteAccount user = do Intra.rmUser uid (userAssets user) ClientStore.lookupClients uid >>= mapM_ (ClientStore.delete uid . (.clientId)) luid <- embed $ qualifyLocal uid - User.internalUpdateSearchIndex uid Nothing + User.internalUpdateSearchIndex uid Events.generateUserEvent uid Nothing (UserDeleted (tUntagged luid)) embed do -- Note: Connections can only be deleted afterwards, since diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index f866fc5a9c..6c1c87b757 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -68,6 +68,8 @@ import Wire.BackgroundJobsPublisher (BackgroundJobPublisher) import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobPublisherRabbitMQ) import Wire.BlockListStore import Wire.BlockListStore.Cassandra +import Wire.BrigAPIAccess (BrigAPIAccess) +import Wire.BrigAPIAccess.Local (interpretBrigAPIAccessLocally) import Wire.BudgetStore import Wire.BudgetStore.Cassandra import Wire.ClientStore (ClientStore) @@ -189,13 +191,12 @@ type RecursiveEffects = '[ AuthenticationSubsystem, UserSubsystem, AppSubsystem, - ClientSubsystem + ClientSubsystem, + BrigAPIAccess, + TeamCollaboratorsSubsystem ] -type NonRecursiveEffects2 = - '[ TeamCollaboratorsSubsystem - ] - `Append` BrigLowerLevelEffects +type NonRecursiveEffects2 = BrigLowerLevelEffects -- | These effects have interpreters which don't depend on each other type BrigLowerLevelEffects = @@ -299,7 +300,7 @@ runRecursiveEffects :: (Members NonRecursiveEffects2 r) => Sem (RecursiveEffects `Append` r) a -> Sem r a -runRecursiveEffects = runClient . runApp . runUser . runAuth +runRecursiveEffects = runTeamCollaborators . runBrigAPIAccess . runClient . runApp . runUser . runAuth where runAuth :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor AuthenticationSubsystem r runAuth = interpretAuthenticationSubsystem runUser @@ -313,6 +314,12 @@ runRecursiveEffects = runClient . runApp . runUser . runAuth runClient :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor ClientSubsystem r runClient = runClientSubsystem runAuth runUser + runBrigAPIAccess :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor BrigAPIAccess r + runBrigAPIAccess = interpretBrigAPIAccessLocally runUser + + runTeamCollaborators :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor TeamCollaboratorsSubsystem r + runTeamCollaborators = interpretTeamCollaboratorsSubsystem runBrigAPIAccess + runBrigToIO :: App.Env -> AppT BrigCanonicalEffects a -> IO a runBrigToIO e (AppT ma) = do let blockedDomains = @@ -510,7 +517,6 @@ runBrigToIO e (AppT ma) = do . interpretTeamCollaboratorsStoreToPostgres . interpretTeamSubsystemToGalleyAPI . samlEmailSubsystemInterpreter - . interpretTeamCollaboratorsSubsystem . runRecursiveEffects . interpretUserGroupSubsystem . maybe diff --git a/services/brig/src/Brig/Index/Eval.hs b/services/brig/src/Brig/Index/Eval.hs index ea72f9aeef..72e768d0cf 100644 --- a/services/brig/src/Brig/Index/Eval.hs +++ b/services/brig/src/Brig/Index/Eval.hs @@ -65,6 +65,9 @@ import Wire.Rpc import Wire.Sem.Logger.TinyLog import Wire.Sem.Metrics (Metrics) import Wire.Sem.Metrics.IO +import Wire.API.Team.Collaborator (TeamCollaboratorsError) +import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore) +import Wire.TeamCollaboratorsStore.Postgres (interpretTeamCollaboratorsStoreToPostgres) import Wire.UserKeyStore (UserKeyStore) import Wire.UserKeyStore.Cassandra import Wire.UserSearch.Migration (MigrationException) @@ -75,6 +78,7 @@ import Wire.UserStore.Postgres (interpretUserStorePostgres) type BrigIndexEffectStack = [ UserKeyStore, UserStore, + TeamCollaboratorsStore, IndexedUserStore, Error IndexedUserStoreError, IndexedUserMigrationStore, @@ -86,6 +90,7 @@ type BrigIndexEffectStack = TinyLog, Input Hasql.Pool, Error UsageError, + Error TeamCollaboratorsError, Error ClientError, Embed IO, Final IO @@ -132,6 +137,7 @@ runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationI runFinal . embedToFinal . throwErrorToIOFinal @ClientError + . throwErrorToIOFinal @TeamCollaboratorsError . throwPostgresUsageErrorToIOFinal . runInputConst pgPool . loggerToTinyLogReqId reqId logger @@ -143,6 +149,7 @@ runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationI . interpretIndexedUserMigrationStoreES bhEnv migrationIndexName . throwErrorToIOFinal @IndexedUserStoreError . interpretIndexedUserStoreES indexedUserStoreConfig + . interpretTeamCollaboratorsStoreToPostgres . userStoreInterpreter . interpretUserKeyStoreCassandra casClient $ action diff --git a/services/brig/src/Brig/User/Search/Index.hs b/services/brig/src/Brig/User/Search/Index.hs index 4c4919729d..68a17f07b1 100644 --- a/services/brig/src/Brig/User/Search/Index.hs +++ b/services/brig/src/Brig/User/Search/Index.hs @@ -364,6 +364,15 @@ indexMapping = mpAnalyzer = Nothing, mpFields = mempty }, + -- teams this user collaborates with (without being a member of them) + "collaborating_teams" + .= MappingProperty + { mpType = MPKeyword, + mpStore = False, + mpIndex = True, + mpAnalyzer = Nothing, + mpFields = mempty + }, "accent_id" .= MappingProperty { mpType = MPByte, diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 4d755d8c61..f47dc7f379 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -572,7 +572,7 @@ evalGalley e = . interpretTeamSubsystem teamSubsystemConfig . runFeaturesConfigSubsystem . runInputSem getAllTeamFeaturesForServer - . interpretTeamCollaboratorsSubsystem + . interpretTeamCollaboratorsSubsystem (interpretBrigAccess (e ^. brig)) . runFederationSubsystem conversationSubsystemConfig.federationProtocols . runInputConst (e ^. reqId) . interpretJobSubsystem From 6ed241f8ded07f99426e80c6781e105ce4399964 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 25 Aug 2026 15:09:49 +0200 Subject: [PATCH 05/16] Remove stray TODO. (I don't understand what it is about, and there is no author to ask.) --- .../src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs index ab2d6360b4..bb0541636b 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs @@ -91,7 +91,6 @@ createTeamCollaboratorImpl zUser user team perms = do guardPermission (tUnqualified zUser) team TeamMember.GetTeamCollaborators InsufficientRights Store.createTeamCollaborator user team perms - -- TODO: Review the event's values generateTeamEvents (tUnqualified zUser) team [EdCollaboratorAdd user (Set.toList perms)] -- Reindex the collaborator with their new collaboration team From 65e1cdc3328b50b0d46c0d494ab270d106344874 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 25 Aug 2026 15:46:45 +0200 Subject: [PATCH 06/16] Remove bogus TODO. (This end-point only updates searchability settings, nothing else.) --- libs/wire-subsystems/src/Wire/BrigAPIAccess.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs index 72a79fec9c..c23b679ed7 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs @@ -106,7 +106,7 @@ data BrigAPIAccess m a where GetAccountConferenceCallingConfigClient :: UserId -> BrigAPIAccess m (Feature ConferenceCallingConfig) GetLocalMLSClients :: Local UserId -> CipherSuiteTag -> BrigAPIAccess m (Set ClientInfo) GetLocalMLSClient :: Local UserId -> ClientId -> CipherSuiteTag -> BrigAPIAccess m ClientInfo - UpdateSearchVisibilityInbound :: -- TODO: what's this? do i need to use this instead of UpdateSearchIndex? + UpdateSearchVisibilityInbound :: Multi.TeamStatus SearchVisibilityInboundConfig -> BrigAPIAccess m () GetUserExportData :: UserId -> BrigAPIAccess m (Maybe TeamExportUser) From 7076c0ad2644f6ab0009f8ac3440ce1a45945516 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 25 Aug 2026 16:50:16 +0200 Subject: [PATCH 07/16] Make BrigAPIAccess.Local interpreter fall back on RPC. This is only where we don't expect to use it. if we're wrong about this, a warning will be logged. --- .../src/Wire/BrigAPIAccess/Local.hs | 37 ++- .../src/Wire/BrigAPIAccess/Rpc.hs | 234 ++++++++++-------- services/brig/src/Brig/App.hs | 6 + .../brig/src/Brig/CanonicalInterpreter.hs | 12 +- 4 files changed, 172 insertions(+), 117 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs index 57af12fc3c..43f04b0175 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs @@ -19,20 +19,47 @@ -- subsystems directly instead of round-tripping over HTTP to itself (as -- 'Wire.BrigAPIAccess.Rpc.interpretBrigAccess' does for every other service). -- --- Only the operations actually needed by code shared with other services (e.g. --- 'Wire.TeamCollaboratorsSubsystem') are implemented; everything else is --- unimplemented until brig itself needs it. +-- Only the operations needed by code shared with other services (e.g. +-- 'Wire.TeamCollaboratorsSubsystem') are implemented locally. Everything else +-- falls back to the RPC handler, pointed at brig itself: correct, but a wasteful +-- round-trip through our own listen socket, so it logs a warning and should be +-- given a local implementation once something actually relies on it. module Wire.BrigAPIAccess.Local where import Imports import Polysemy +import Polysemy.Error (Error) +import Polysemy.Input (runInputConst) +import Polysemy.TinyLog (TinyLog) +import Polysemy.TinyLog qualified as Log +import System.Logger.Message qualified as Log +import Util.Options (Endpoint) import Wire.BrigAPIAccess +import Wire.BrigAPIAccess.Rpc (brigAccessRpcHandler) +import Wire.ParseException (ParseException) +import Wire.Rpc (Rpc) +import Wire.RpcException (RpcException) import Wire.UserSubsystem (UserSubsystem) import Wire.UserSubsystem qualified as UserSubsystem +-- | The 'Endpoint' is brig's own; it is only used for the operations that have +-- no local implementation yet. interpretBrigAPIAccessLocally :: + forall r. + ( Member TinyLog r, + Member Rpc r, + Member (Error ParseException) r, + Member (Error RpcException) r + ) => + Endpoint -> InterpreterFor UserSubsystem r -> InterpreterFor BrigAPIAccess r -interpretBrigAPIAccessLocally runUser = interpret $ \case +interpretBrigAPIAccessLocally selfEndpoint runUser = interpret $ \case UpdateSearchIndex uid -> runUser (UserSubsystem.internalUpdateSearchIndex uid) - _ -> error "BrigAPIAccess.Local: operation not implemented" -- TODO: shouldn't we make an effort and at least fall back to the Rpc interpreter somehow? + other -> selfRpc other + where + selfRpc :: forall m x. BrigAPIAccess m x -> Sem r x + selfRpc action = do + Log.warn $ + Log.msg (Log.val "BrigAPIAccess.Local: no local implementation, calling brig over HTTP") + runInputConst selfEndpoint (brigAccessRpcHandler action) diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs index e42a479139..b96d0abeea 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs @@ -82,115 +82,131 @@ interpretBrigAccess :: Sem (BrigAPIAccess ': r) a -> Sem r a interpretBrigAccess brigEndpoint = - interpret $ - runInputConst brigEndpoint . \case - GetConnectionsUnqualified uids muids mrel -> do - getConnectionsUnqualified uids muids mrel - GetConnections uids mquids mrel -> do - getConnections uids mquids mrel - PutConnectionInternal uc -> do - putConnectionInternal uc - ReauthUser uid reauth -> do - reAuthUser uid reauth - LookupActivatedUsers uids -> do - lookupActivatedUsers uids - GetUsers uids -> do - getUsers uids - DeleteUser uid -> do - deleteUser uid - GetContactList uid -> do - getContactList uid - GetUserExportData uid -> do - getUserExportData uid - GetSize tid -> do - getSize tid - LookupClients uids -> do - lookupClients uids - LookupClientsFull uids -> do - lookupClientsFull uids - NotifyClientsAboutLegalHoldRequest self other pk -> do - notifyClientsAboutLegalHoldRequest self other pk - GetLegalHoldAuthToken uid mpwd -> do - getLegalHoldAuthToken uid mpwd - AddLegalHoldClientToUserEither uid conn pks lpk -> do - addLegalHoldClientToUser uid conn pks lpk - RemoveLegalHoldClientFromUser uid -> do - removeLegalHoldClientFromUser uid - GetAccountConferenceCallingConfigClient uid -> do - getAccountConferenceCallingConfigClient uid - GetLocalMLSClients qusr ss -> do - getLocalMLSClients qusr ss - GetLocalMLSClient qusr cid ss -> do - getLocalMLSClient qusr cid ss - UpdateSearchVisibilityInbound status -> do - updateSearchVisibilityInbound status - DeleteBot convId botId -> - deleteBot convId botId - UpdateSearchIndex uid -> updateSearchIndex uid - GetAccountsBy localGetBy -> - getAccountsBy localGetBy - GetUsersByVariousKeys uids handles emails includePendingInvitations -> - getUsersByVariousKeys uids handles emails includePendingInvitations - CreateGroupInternal managedBy teamId creatorUserId newGroup -> - createGroupInternal managedBy teamId creatorUserId newGroup - GetGroupsInternal tid mbFilter mbManagedBy startIndex mbCount -> - getGroupsInternal tid mbFilter mbManagedBy startIndex mbCount - GetGroupInternal tid gid includeChannels -> - getGroupInternal tid gid includeChannels - UpdateGroup req -> - updateGroup req - DeleteGroupInternal managedBy teamId groupId -> - deleteGroupInternal managedBy teamId groupId - GetAppIdsForTeam teamId -> - getAppIdsForTeam teamId - SetAccountStatus uid status -> - setAccountStatus uid status - DeleteApp teamId uid -> - deleteApp teamId uid - CreateSAML uref buid teamid name managedBy handle richInfo mLocale role -> - createSAML uref buid teamid name managedBy handle richInfo mLocale role - CreateNoSAML extId email uid teamid uname locale role -> - createNoSAML extId email uid teamid uname locale role - UpdateEmail uid email activation -> - updateEmail uid email activation - GetAccount havePending uid -> - getAccount havePending uid - GetAccountByHandle handle -> - getByHandle handle - GetByEmail email -> - getByEmail email - SetName uid name -> - setName uid name - SetHandle uid handle -> - setHandle uid handle - SetManagedBy uid managedBy -> - setManagedBy uid managedBy - DeletePendingEmailUpdate uid -> - deletePendingEmailUpdate uid - SetSSOId uid ssoId -> - setSSOId uid ssoId - SetRichInfo uid richInfo -> - setRichInfo uid richInfo - SetLocale uid mLocale -> - setLocale uid mLocale - GetRichInfo uid -> - getRichInfo uid - CheckHandleAvailable handle -> - checkHandleAvailable handle - SsoLogin uid mLabel -> - ssoLogin uid mLabel - GetStatus uid -> - getStatus uid - GetStatusMaybe uid -> - getStatusMaybe uid - SetStatus uid status -> - setStatus uid status - GetDefaultUserLocale -> - getDefaultUserLocale - CheckAdminGetTeamId uid -> - checkAdminGetTeamId uid - SendSAMLIdPChangedEmail notif -> - sendSAMLIdPChangedEmail notif + interpret $ runInputConst brigEndpoint . brigAccessRpcHandler + +-- | Handles a single 'BrigAPIAccess' action by calling brig over HTTP. +-- +-- Exposed separately from 'interpretBrigAccess' so that +-- 'Wire.BrigAPIAccess.Local.interpretBrigAPIAccessLocally' can delegate the +-- actions it does not implement itself. 'BrigAPIAccess' is a first-order +-- effect, so @m@ is unconstrained and any handler's action can be passed here. +brigAccessRpcHandler :: + ( Member TinyLog r, + Member Rpc r, + Member (Error ParseException) r, + Member (Error RpcException) r, + Member (Input Endpoint) r + ) => + BrigAPIAccess m a -> + Sem r a +brigAccessRpcHandler = \case + GetConnectionsUnqualified uids muids mrel -> do + getConnectionsUnqualified uids muids mrel + GetConnections uids mquids mrel -> do + getConnections uids mquids mrel + PutConnectionInternal uc -> do + putConnectionInternal uc + ReauthUser uid reauth -> do + reAuthUser uid reauth + LookupActivatedUsers uids -> do + lookupActivatedUsers uids + GetUsers uids -> do + getUsers uids + DeleteUser uid -> do + deleteUser uid + GetContactList uid -> do + getContactList uid + GetUserExportData uid -> do + getUserExportData uid + GetSize tid -> do + getSize tid + LookupClients uids -> do + lookupClients uids + LookupClientsFull uids -> do + lookupClientsFull uids + NotifyClientsAboutLegalHoldRequest self other pk -> do + notifyClientsAboutLegalHoldRequest self other pk + GetLegalHoldAuthToken uid mpwd -> do + getLegalHoldAuthToken uid mpwd + AddLegalHoldClientToUserEither uid conn pks lpk -> do + addLegalHoldClientToUser uid conn pks lpk + RemoveLegalHoldClientFromUser uid -> do + removeLegalHoldClientFromUser uid + GetAccountConferenceCallingConfigClient uid -> do + getAccountConferenceCallingConfigClient uid + GetLocalMLSClients qusr ss -> do + getLocalMLSClients qusr ss + GetLocalMLSClient qusr cid ss -> do + getLocalMLSClient qusr cid ss + UpdateSearchVisibilityInbound status -> do + updateSearchVisibilityInbound status + DeleteBot convId botId -> + deleteBot convId botId + UpdateSearchIndex uid -> updateSearchIndex uid + GetAccountsBy localGetBy -> + getAccountsBy localGetBy + GetUsersByVariousKeys uids handles emails includePendingInvitations -> + getUsersByVariousKeys uids handles emails includePendingInvitations + CreateGroupInternal managedBy teamId creatorUserId newGroup -> + createGroupInternal managedBy teamId creatorUserId newGroup + GetGroupsInternal tid mbFilter mbManagedBy startIndex mbCount -> + getGroupsInternal tid mbFilter mbManagedBy startIndex mbCount + GetGroupInternal tid gid includeChannels -> + getGroupInternal tid gid includeChannels + UpdateGroup req -> + updateGroup req + DeleteGroupInternal managedBy teamId groupId -> + deleteGroupInternal managedBy teamId groupId + GetAppIdsForTeam teamId -> + getAppIdsForTeam teamId + SetAccountStatus uid status -> + setAccountStatus uid status + DeleteApp teamId uid -> + deleteApp teamId uid + CreateSAML uref buid teamid name managedBy handle richInfo mLocale role -> + createSAML uref buid teamid name managedBy handle richInfo mLocale role + CreateNoSAML extId email uid teamid uname locale role -> + createNoSAML extId email uid teamid uname locale role + UpdateEmail uid email activation -> + updateEmail uid email activation + GetAccount havePending uid -> + getAccount havePending uid + GetAccountByHandle handle -> + getByHandle handle + GetByEmail email -> + getByEmail email + SetName uid name -> + setName uid name + SetHandle uid handle -> + setHandle uid handle + SetManagedBy uid managedBy -> + setManagedBy uid managedBy + DeletePendingEmailUpdate uid -> + deletePendingEmailUpdate uid + SetSSOId uid ssoId -> + setSSOId uid ssoId + SetRichInfo uid richInfo -> + setRichInfo uid richInfo + SetLocale uid mLocale -> + setLocale uid mLocale + GetRichInfo uid -> + getRichInfo uid + CheckHandleAvailable handle -> + checkHandleAvailable handle + SsoLogin uid mLabel -> + ssoLogin uid mLabel + GetStatus uid -> + getStatus uid + GetStatusMaybe uid -> + getStatusMaybe uid + SetStatus uid status -> + setStatus uid status + GetDefaultUserLocale -> + getDefaultUserLocale + CheckAdminGetTeamId uid -> + checkAdminGetTeamId uid + SendSAMLIdPChangedEmail notif -> + sendSAMLIdPChangedEmail notif brigRequest :: (Member Rpc r, Member (Input Endpoint) r) => (Request -> Request) -> Sem r (Response (Maybe LByteString)) brigRequest req = do diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 6c2145ea8c..27812d06b4 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -36,6 +36,7 @@ module Brig.App cargoholdLens, galleyLens, galleyEndpointLens, + brigEndpointLens, sparEndpointLens, gundeckEndpointLens, cargoholdEndpointLens, @@ -189,6 +190,10 @@ data Env = Env { cargohold :: RPC.Request, galley :: RPC.Request, galleyEndpoint :: Endpoint, + -- | Brig's own listen address. Used only to call ourselves over HTTP for + -- 'BrigAPIAccess' operations that have no local implementation yet; see + -- 'Wire.BrigAPIAccess.Local'. + brigEndpoint :: Endpoint, sparEndpoint :: Endpoint, gundeckEndpoint :: Endpoint, cargoholdEndpoint :: Endpoint, @@ -307,6 +312,7 @@ newEnv opts = do { cargohold = mkEndpoint $ opts.cargohold, galley = mkEndpoint $ opts.galley, galleyEndpoint = opts.galley, + brigEndpoint = opts.brig, sparEndpoint = opts.spar, gundeckEndpoint = opts.gundeck, cargoholdEndpoint = opts.cargohold, diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 6c1c87b757..4414567c91 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -47,6 +47,7 @@ import Polysemy.Input (Input, runInputConst) import Polysemy.Internal.Kind import Polysemy.Resource import Polysemy.TinyLog (TinyLog) +import Util.Options (Endpoint) import Wire.API.Error (ErrorS, errorToWai) import Wire.API.Error.Galley import Wire.API.Federation.Client qualified @@ -132,6 +133,7 @@ import Wire.PropertySubsystem.Interpreter import Wire.RateLimit import Wire.RateLimit.Interpreter import Wire.Rpc +import Wire.RpcException (RpcException) import Wire.SAMLEmailSubsystem import Wire.SAMLEmailSubsystem.Interpreter import Wire.SFT (SFT, interpretSFT) @@ -277,6 +279,7 @@ type BrigLowerLevelEffects = Embed Cas.Client, Error ClientError, Error ParseException, + Error RpcException, Error ErrorCall, Error SomeException, Error HttpError, @@ -298,9 +301,11 @@ type BrigLowerLevelEffects = -- Cloned from "Wire.MiniBackend". runRecursiveEffects :: (Members NonRecursiveEffects2 r) => + -- | Brig's own endpoint; see 'interpretBrigAPIAccessLocally'. + Endpoint -> Sem (RecursiveEffects `Append` r) a -> Sem r a -runRecursiveEffects = runTeamCollaborators . runBrigAPIAccess . runClient . runApp . runUser . runAuth +runRecursiveEffects selfEndpoint = runTeamCollaborators . runBrigAPIAccess . runClient . runApp . runUser . runAuth where runAuth :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor AuthenticationSubsystem r runAuth = interpretAuthenticationSubsystem runUser @@ -315,7 +320,7 @@ runRecursiveEffects = runTeamCollaborators . runBrigAPIAccess . runClient . runA runClient = runClientSubsystem runAuth runUser runBrigAPIAccess :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor BrigAPIAccess r - runBrigAPIAccess = interpretBrigAPIAccessLocally runUser + runBrigAPIAccess = interpretBrigAPIAccessLocally selfEndpoint runUser runTeamCollaborators :: forall r. (Members NonRecursiveEffects2 r) => InterpreterFor TeamCollaboratorsSubsystem r runTeamCollaborators = interpretTeamCollaboratorsSubsystem runBrigAPIAccess @@ -440,6 +445,7 @@ runBrigToIO e (AppT ma) = do . rethrowHttpErrorIO . runError @SomeException . mapError @ErrorCall SomeException + . mapError @RpcException SomeException . mapError @ParseException SomeException . mapError clientErrorToHttpError . interpretClientToIO e.casClient @@ -517,7 +523,7 @@ runBrigToIO e (AppT ma) = do . interpretTeamCollaboratorsStoreToPostgres . interpretTeamSubsystemToGalleyAPI . samlEmailSubsystemInterpreter - . runRecursiveEffects + . runRecursiveEffects e.brigEndpoint . interpretUserGroupSubsystem . maybe runEnterpriseLoginSubsystemNoConfig From dc6654742a6bbc4110fd41aaaad736cbd75b1fcf Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 25 Aug 2026 16:51:05 +0200 Subject: [PATCH 08/16] Test users collaborating with more than one team. --- integration/test/Test/TeamCollaborators.hs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index 55a8497401..642dad537e 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -413,3 +413,13 @@ testSearchFindsCollaborator = do for_ [owner, alice] $ assertFinds collab3Name' ([] @Value) for_ [otherOwner, bob] $ assertFinds collab3Name' [collab3] + + -- Can one user collaborate in multiple teams without breaking search? + (_thirdOwner, _thirdTeam, [multiCollab]) <- createTeam OwnDomain 2 + multiCollabName <- multiCollab %. "name" & asString + + addTeamCollaborator owner team multiCollab ["implicit_connection"] >>= assertSuccess + addTeamCollaborator otherOwner otherTeam multiCollab ["implicit_connection"] >>= assertSuccess + + for_ [owner, alice] $ assertFinds multiCollabName [multiCollab] + for_ [otherOwner, bob] $ assertFinds multiCollabName [multiCollab] From b228fdbea3cc378be69777ebbb83dc0d1b416ec4 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 26 Aug 2026 08:58:49 +0200 Subject: [PATCH 09/16] make sanitize-pr --- services/brig/src/Brig/Index/Eval.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/brig/src/Brig/Index/Eval.hs b/services/brig/src/Brig/Index/Eval.hs index 72e768d0cf..d703437a3a 100644 --- a/services/brig/src/Brig/Index/Eval.hs +++ b/services/brig/src/Brig/Index/Eval.hs @@ -51,6 +51,7 @@ import Polysemy.TinyLog (TinyLog) import System.Logger qualified as Log import System.Logger.Class (Logger) import Util.Options +import Wire.API.Team.Collaborator (TeamCollaboratorsError) import Wire.ClientSubsystem.Error (ClientError) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.GalleyAPIAccess.Rpc @@ -65,7 +66,6 @@ import Wire.Rpc import Wire.Sem.Logger.TinyLog import Wire.Sem.Metrics (Metrics) import Wire.Sem.Metrics.IO -import Wire.API.Team.Collaborator (TeamCollaboratorsError) import Wire.TeamCollaboratorsStore (TeamCollaboratorsStore) import Wire.TeamCollaboratorsStore.Postgres (interpretTeamCollaboratorsStoreToPostgres) import Wire.UserKeyStore (UserKeyStore) From 83768bd6ff2a43351a330d0cebb2d6d230728bec Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 13 Aug 2026 15:43:31 +0200 Subject: [PATCH 10/16] Changelog, failing integration tests. --- ...nt-apps-and-collaborators-as-members-in-get-team-size | 1 + integration/test/Test/Apps.hs | 7 +++++++ integration/test/Test/TeamCollaborators.hs | 9 ++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1-api-changes/WPB-27169-do-not-count-apps-and-collaborators-as-members-in-get-team-size diff --git a/changelog.d/1-api-changes/WPB-27169-do-not-count-apps-and-collaborators-as-members-in-get-team-size b/changelog.d/1-api-changes/WPB-27169-do-not-count-apps-and-collaborators-as-members-in-get-team-size new file mode 100644 index 0000000000..a052dda291 --- /dev/null +++ b/changelog.d/1-api-changes/WPB-27169-do-not-count-apps-and-collaborators-as-members-in-get-team-size @@ -0,0 +1 @@ +Do not count apps and collaborators as members in get-team-size. New schema: `{"teamSize": num, "apps": num, "collaborators": num}` (non-overlapping). `teamSize` has been the label since the dawn of time, only the other two have changed. The protobuf schema for TeamEvents changed accordingly. diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index 4428f5e732..91080552b9 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -68,6 +68,13 @@ testCreateGetApp sameOrOtherDomain = do void $ assertNoEvent 5 wsRegularMember pure (appId, cookie) + -- team size counts apps separately. (they are not members.) + bindResponse (getTeamSize owner tid) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "teamSize" `shouldMatchInt` 2 + resp.json %. "apps" `shouldMatchInt` 1 + resp.json %. "collaborators" `shouldMatchInt` 0 + -- Verify that the team.member-join event is in the team notifications queue bindResponse (getTeamNotifications regularMember (Just lastTeamNotif)) $ \resp -> do resp.status `shouldMatchInt` 200 diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index 642dad537e..96192faaa7 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -19,7 +19,7 @@ module Test.TeamCollaborators where -import qualified API.Brig as BrigP +import API.Brig as BrigP import qualified API.BrigInternal as BrigI import API.Common (randomName) import API.Galley @@ -63,6 +63,13 @@ testCreateTeamCollaborator = do res %. "team" `shouldMatch` team res %. "permissions" `shouldMatch` ["create_team_conversation", "implicit_connection"] + -- team size counts collaborators separately + bindResponse (getTeamSize owner team) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "teamSize" `shouldMatchInt` 2 + resp.json %. "apps" `shouldMatchInt` 0 + resp.json %. "collaborators" `shouldMatchInt` 1 + testTeamCollaboratorEndpointsForbiddenForOtherTeams :: (HasCallStack) => App () testTeamCollaboratorEndpointsForbiddenForOtherTeams = do (owner, _team, _members) <- createTeam OwnDomain 2 From cb3d0aef65fbfe7fe8883729b4353743ea481142 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 14 Aug 2026 09:17:47 +0200 Subject: [PATCH 11/16] [drive-by] regenerated wire-api golden tests. --- .../golden/testObject_Event_meeting_create_manual_1.json | 8 ++++---- .../golden/testObject_Event_meeting_delete_manual_1.json | 8 ++++---- .../testObject_Event_meeting_member_add_manual_1.json | 8 ++++---- .../testObject_Event_meeting_member_add_manual_2.json | 8 ++++---- .../golden/testObject_Event_meeting_update_manual_1.json | 8 ++++---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json index faaf77d4b6..7aac908df0 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "time": "2018-01-01T00:00:00.000Z", "type": "meeting.create", "via": "user" diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json index 5bae8ab62d..6ff021670e 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "time": "2018-01-01T00:00:00.000Z", "type": "meeting.delete", "via": "user" diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json index 8d40ebe09a..cad3c3e276 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "time": "2018-01-01T00:00:00.000Z", "type": "meeting.member-add", "via": "user" diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json index 628f1bf141..00f1ce4f3b 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "team": "00000002-0000-0000-0000-000000000002", "time": "2018-01-01T00:00:00.000Z", "type": "meeting.member-add", diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json index e1754d221e..42c3c2c378 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "time": "2018-01-01T00:00:00.000Z", "type": "meeting.update", "via": "user" From 0a378bd44c0a84457415087457041e22f7e6c08e Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 26 Aug 2026 14:58:39 +0200 Subject: [PATCH 12/16] Do not count apps and collaborators as members in get-team-size. New schema: `{"teamSize": num, "apps": num, "collaborators": num}` (non-overlapping). --- .../proto/TeamEvents.proto | 7 ++- libs/wire-api/src/Wire/API/Team/Size.hs | 51 ++++--------------- .../Test/Wire/API/Golden/Manual/TeamSize.hs | 6 +-- .../test/golden/testObject_TeamSize_1.json | 6 +-- .../test/golden/testObject_TeamSize_2.json | 6 +-- .../test/golden/testObject_TeamSize_3.json | 6 +-- .../Wire/IndexedUserStore/ElasticSearch.hs | 2 +- libs/wire-subsystems/src/Wire/TeamJournal.hs | 21 ++++---- .../src/Wire/UserSubsystem/Interpreter.hs | 4 +- .../Wire/MockInterpreters/IndexedUserStore.hs | 6 ++- services/brig/test/integration/API/Team.hs | 2 +- .../galley/src/Galley/API/LegalHold/Team.hs | 11 ++-- services/galley/src/Galley/API/Teams.hs | 33 ++++++------ 13 files changed, 65 insertions(+), 96 deletions(-) diff --git a/libs/types-common-journal/proto/TeamEvents.proto b/libs/types-common-journal/proto/TeamEvents.proto index 8bd25c21cc..0552704b7a 100644 --- a/libs/types-common-journal/proto/TeamEvents.proto +++ b/libs/types-common-journal/proto/TeamEvents.proto @@ -22,10 +22,9 @@ message TeamEvent { // are guaranteed to be present). // // for backwards compatibility, clients should make these - // fields optional, and fall back to using `member_count` if - // they are missing. - required int32 member_count_regular = 4; - required int32 member_count_app = 5; + // fields optional, and assume '0' if missing. + required int32 apps = 4; + required int32 collaborators = 5; } enum EventType { diff --git a/libs/wire-api/src/Wire/API/Team/Size.hs b/libs/wire-api/src/Wire/API/Team/Size.hs index d751769a90..ba84e40039 100644 --- a/libs/wire-api/src/Wire/API/Team/Size.hs +++ b/libs/wire-api/src/Wire/API/Team/Size.hs @@ -17,66 +17,33 @@ module Wire.API.Team.Size ( TeamSize (..), - teamSizeTotal, - updateTeamSize, ) where import Control.Lens ((?~)) import Data.Aeson qualified as A -import Data.Aeson.Types qualified as A import Data.OpenApi qualified as S import Data.Schema import Imports import Numeric.Natural import Test.QuickCheck (arbitrarySizedNatural) -import Wire.API.User.Search import Wire.Arbitrary data TeamSize = TeamSize - { regulars :: Natural, - apps :: Natural + { teamSize :: Natural, + apps :: Natural, + collaborators :: Natural } deriving (Show, Eq) deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema TeamSize) --- | Total team members (regulars + apps). -teamSizeTotal :: TeamSize -> Natural -teamSizeTotal ts = ts.regulars + ts.apps - --- Increase or decrease a team size component, depending on user type. - --- If the result of a decrease is <0, it is set to 1 (regulars) or 0 --- (apps). This handles corner cases where ES reports lower numbers --- from the past. -updateTeamSize :: UserTypeFilter -> TeamSize -> Int -> TeamSize -updateTeamSize = go - where - go :: UserTypeFilter -> TeamSize -> Int -> TeamSize - go UserTypeFilterRegular (TeamSize rs as) n = TeamSize (upd 1 rs n) as - go UserTypeFilterApp (TeamSize rs as) n = TeamSize rs (upd 0 as n) - - upd :: Int -> Natural -> Int -> Natural - upd low n i = fromIntegral . max low $ fromIntegral n + i - instance ToSchema TeamSize where schema = - objectWithDocModifier (description ?~ "Team member counts broken down by user type.") $ - fromTeamSize .= tripleSchema `withParser` validate - where - fromTeamSize :: TeamSize -> (Natural, Natural, Maybe Natural) - fromTeamSize ts = (ts.regulars, ts.apps, Just (teamSizeTotal ts)) - tripleSchema :: ObjectSchema SwaggerDoc (Natural, Natural, Maybe Natural) - tripleSchema = - (,,) - <$> (\(r, _, _) -> r) .= fieldWithDocModifier "teamSizeRegulars" (description ?~ "Number of regular users in team.") schema - <*> (\(_, a, _) -> a) .= fieldWithDocModifier "teamSizeApps" (description ?~ "Number of apps in team.") schema - <*> (\(_, _, t) -> t) .= maybe_ (optFieldWithDocModifier "teamSize" (description ?~ "Total team members (teamSizeRegulars + teamSizeApps).") schema) - validate :: (Natural, Natural, Maybe Natural) -> A.Parser TeamSize - validate (r, a, Nothing) = pure TeamSize {regulars = r, apps = a} - validate (r, a, Just t) - | r + a == t = pure TeamSize {regulars = r, apps = a} - | otherwise = fail $ "teamSize (" <> show t <> ") != regulars + apps (" <> show (r + a) <> ")" + objectWithDocModifier (description ?~ "Number of team members (paid seats, always regular users), appps, collaborators.") $ + TeamSize + <$> (.teamSize) .= field "teamSize" schema + <*> (.apps) .= field "apps" schema + <*> (.collaborators) .= field "collaborators" schema instance Arbitrary TeamSize where - arbitrary = TeamSize <$> arbitrarySizedNatural <*> arbitrarySizedNatural + arbitrary = TeamSize <$> arbitrarySizedNatural <*> arbitrarySizedNatural <*> arbitrarySizedNatural diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs index 8137fe0550..82757e4e94 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs @@ -21,10 +21,10 @@ import Imports import Wire.API.Team.Size testObject_TeamSize_1 :: TeamSize -testObject_TeamSize_1 = TeamSize 0 0 +testObject_TeamSize_1 = TeamSize 0 0 0 testObject_TeamSize_2 :: TeamSize -testObject_TeamSize_2 = TeamSize 100 400 +testObject_TeamSize_2 = TeamSize 100 400 7 testObject_TeamSize_3 :: TeamSize -testObject_TeamSize_3 = TeamSize (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64) +testObject_TeamSize_3 = TeamSize (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64) diff --git a/libs/wire-api/test/golden/testObject_TeamSize_1.json b/libs/wire-api/test/golden/testObject_TeamSize_1.json index 92dda71f2d..e76772592d 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_1.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_1.json @@ -1,5 +1,5 @@ { - "teamSize": 0, - "teamSizeApps": 0, - "teamSizeRegulars": 0 + "apps": 0, + "collaborators": 0, + "teamSize": 0 } diff --git a/libs/wire-api/test/golden/testObject_TeamSize_2.json b/libs/wire-api/test/golden/testObject_TeamSize_2.json index 5b9794591d..293cfd45e5 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_2.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_2.json @@ -1,5 +1,5 @@ { - "teamSize": 500, - "teamSizeApps": 400, - "teamSizeRegulars": 100 + "apps": 400, + "collaborators": 7, + "teamSize": 100 } diff --git a/libs/wire-api/test/golden/testObject_TeamSize_3.json b/libs/wire-api/test/golden/testObject_TeamSize_3.json index 421801b4b4..2145f501bf 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_3.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_3.json @@ -1,5 +1,5 @@ { - "teamSize": 3.689348814741910323e19, - "teamSizeApps": 1.8446744073709551615e19, - "teamSizeRegulars": 1.8446744073709551615e19 + "apps": 1.8446744073709551615e19, + "collaborators": 1.8446744073709551615e19, + "teamSize": 1.8446744073709551615e19 } diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index 07572a2985..1aab52f3b2 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -90,7 +90,7 @@ getTeamSizeImpl cfg tid = do result <- either (embed . throwIO . IndexLookupError) pure (r :: Either ES.EsError (ES.SearchResult UserDoc)) let aggs = fromMaybe mempty (ES.aggregations result) getCount name = maybe 0 (.filterDocCount) $ M.lookup name aggs >>= parseMaybe (parseJSON @FilterResult) - pure $ TeamSize (getCount "regulars") (getCount "apps") + pure $ TeamSize (getCount "teamSize") (getCount "apps") (getCount "collaborators") where teamQ = termQ "team" (idToText tid) diff --git a/libs/wire-subsystems/src/Wire/TeamJournal.hs b/libs/wire-subsystems/src/Wire/TeamJournal.hs index 9ae5ec1044..bd65923296 100644 --- a/libs/wire-subsystems/src/Wire/TeamJournal.hs +++ b/libs/wire-subsystems/src/Wire/TeamJournal.hs @@ -112,14 +112,13 @@ journalEvent typ tid dat tim = do -- utils evData :: TeamSize -> [UserId] -> Maybe Currency.Alpha -> TeamEvent'EventData -evData teamSize@(TeamSize regulars apps) billingUserIds cur = - defMessage - & T.memberCount .~ memberCountTotal - & T.billingUser .~ (toBytes <$> billingUserIds) - & T.maybe'currency .~ (pack . show <$> cur) - & T.memberCountRegular .~ memberCountRegulars - & T.memberCountApp .~ memberCountApps - where - memberCountTotal, memberCountRegulars, memberCountApps :: Int32 - (memberCountTotal, memberCountRegulars, memberCountApps) = - (fromIntegral $ teamSizeTotal teamSize, fromIntegral regulars, fromIntegral apps) +evData + (TeamSize (fromIntegral -> teamSize) (fromIntegral -> apps) (fromIntegral -> collaborators)) + billingUserIds + cur = + defMessage + & T.memberCount .~ teamSize + & T.billingUser .~ (toBytes <$> billingUserIds) + & T.maybe'currency .~ (pack . show <$> cur) + & T.apps .~ apps + & T.collaborators .~ collaborators diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index f78029cebd..a05edddfe7 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -313,8 +313,8 @@ internalFindTeamInvitationImpl (Just e) c = NotAllowed -> throwGuardFailed TeamInviteSetToNotAllowed maxSize <- maxTeamSize <$> input - teamSize <- teamSizeTotal <$> IndexedUserStore.getTeamSize tid - when (teamSize >= fromIntegral maxSize) $ + tSize <- (.teamSize) <$> IndexedUserStore.getTeamSize tid + when (tSize >= fromIntegral maxSize) $ throw UserSubsystemTooManyTeamMembers -- FUTUREWORK: The above can easily be done/tested in the intra call. -- Remove after the next release. diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs index b77869840f..1fefcaeeb3 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs @@ -87,12 +87,16 @@ inMemoryIndexedUserStoreInterpreter = error "IndexedUserStore: unimplemented in memory interpreter" GetTeamSize tid -> gets $ \index -> - let regulars = help [Just UserTypeRegular, Nothing] + let teamSize = help [Just UserTypeRegular, Nothing] apps = help [Just UserTypeApp] help allowedTypes = fromIntegral . length $ Map.filter (\(doc, _) -> doc.udTeam == Just tid && doc.udType `elem` allowedTypes) index.docs + collaborators = + fromIntegral + . length + $ Map.filter (\(doc, _) -> tid `elem` doc.udCollaboratingTeams) index.docs in TeamSize {..} upsertImpl :: (Member (State UserIndex) r) => ES.DocId -> UserDoc -> ES.VersionControl -> Sem r () diff --git a/services/brig/test/integration/API/Team.hs b/services/brig/test/integration/API/Team.hs index 66e1ead9e2..708fc1c7c4 100644 --- a/services/brig/test/integration/API/Team.hs +++ b/services/brig/test/integration/API/Team.hs @@ -154,7 +154,7 @@ testTeamSize brig req = do void $ get (req tid uid) Sem r () ensureNotTooLargeToActivateLegalHold tid = do - teamSize <- getSize tid - unlessM (teamSizeBelowLimit teamSize) $ + tSize <- (.teamSize) <$> getSize tid + unlessM (teamSizeBelowLimit tSize) $ throwS @'CannotEnableLegalHoldServiceLargeTeam teamSizeBelowLimit :: ( Member (Input FanoutLimit) r, Member (Input (FeatureDefaults LegalholdConfig)) r ) => - TeamSize -> + Natural -> Sem r Bool -teamSizeBelowLimit (fromIntegral . teamSizeTotal -> teamSize) = do - limit :: Int <- fromIntegral . fromRange <$> input @FanoutLimit +teamSizeBelowLimit teamSize = do + limit <- fromIntegral . fromRange <$> input @FanoutLimit let withinLimit = teamSize <= limit featureLegalHold <- input @(FeatureDefaults LegalholdConfig) case featureLegalHold of diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 181a720ae8..59b86bf575 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -79,6 +79,7 @@ import Galley.API.Teams.Notifications qualified as APITeamQueue import Galley.App import Galley.Types.Error as Galley import Imports hiding (forkIO) +import Numeric.Natural import Polysemy import Polysemy.Error import Polysemy.Input @@ -279,8 +280,8 @@ updateTeamStatus tid (TeamStatusUpdate newStatus cur) = do -- We could also write `updateTeamSize 1 size 0` here, but it seems clearer to do it -- inline. teamSize <- do - (TeamSize numRegulars numApps) <- E.getSize tid - pure $ TeamSize (max 1 numRegulars) numApps + (TeamSize numRegulars numApps numCollaborators) <- E.getSize tid + pure $ TeamSize (max 1 numRegulars) numApps numCollaborators Journal.teamActivate tid teamSize c teamCreationTime runJournal _ _ = throwS @'InvalidTeamStatusUpdate validateTransition :: (Member (ErrorS 'InvalidTeamStatusUpdate) r) => (TeamStatus, TeamStatus) -> Sem r Bool @@ -788,7 +789,9 @@ deleteTeamMember' lusr zcon tid remove mBody = do _ -> UserTypeFilterRegular teamSizeAfterDelete <- do before <- E.getSize tid - pure $ updateTeamSize uType before (-1) + pure case uType of + UserTypeFilterRegular -> before {teamSize = before.teamSize - 1} + UserTypeFilterApp -> before {apps = before.apps - 1} E.deleteUser remove case uType of UserTypeFilterRegular -> pure () @@ -1035,7 +1038,7 @@ ensureNotTooLargeForLegalHold :: Member FeaturesConfigSubsystem r ) => TeamId -> - TeamSize -> + Natural -> Sem r () ensureNotTooLargeForLegalHold tid teamSize = whenM (isLegalHoldEnabledForTeam tid) $ @@ -1073,8 +1076,10 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do E.getUser (new ^. userId) <&> \case Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp _ -> UserTypeFilterRegular - pure $ updateTeamSize uType n 1 - ensureNotTooLargeForLegalHold tid sizeAfterAdd + pure case uType of + UserTypeFilterRegular -> n {teamSize = n.teamSize + 1} + UserTypeFilterApp -> n {apps = n.apps + 1} + ensureNotTooLargeForLegalHold tid (sizeAfterAdd.teamSize + sizeAfterAdd.apps + sizeAfterAdd.collaborators) admins <- E.getTeamAdmins tid let admins' = [new ^. userId | isAdminOrOwner (new ^. M.permissions)] <> admins @@ -1110,10 +1115,10 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do Sem r TeamSize ensureNotTooLarge teamid = do o <- input - teamSize <- E.getSize teamid - unless (teamSizeTotal teamSize < fromIntegral (o ^. settings . maxTeamSize)) $ + tSize <- E.getSize teamid + unless (teamSize tSize < fromIntegral (o ^. settings . maxTeamSize)) $ throwS @'TooManyTeamMembers - pure teamSize + pure tSize getBindingTeamMembers :: ( Member (ErrorS 'TeamNotFound) r, @@ -1155,14 +1160,8 @@ canUserJoinTeam tid = do lhEnabled <- isLegalHoldEnabledForTeam tid when lhEnabled $ do sizeBeforeJoin <- E.getSize tid - let uType = - -- We do not have a `UserId` to check here. Also, - -- `canUserJoinTeam` is called by Brig during user - -- registration via invitation (POST /register), where apps - -- never go. So it is safe to assume "regular" - UserTypeFilterRegular - let sizeAfterJoin = updateTeamSize uType sizeBeforeJoin 1 - ensureNotTooLargeForLegalHold tid sizeAfterJoin + let sizeAfterJoin = sizeBeforeJoin {teamSize = sizeBeforeJoin.teamSize + 1} + ensureNotTooLargeForLegalHold tid (sizeAfterJoin.teamSize + sizeAfterJoin.apps + sizeAfterJoin.collaborators) -- | Modify and get visibility type for a team (internal, no user permission checks) getSearchVisibilityInternal :: From 66579f8871316c71579ba6d2fca1203c3142fcaa Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 10:21:15 +0200 Subject: [PATCH 13/16] Guard legalhold team size limit when adding collaborators. --- .../src/Galley/API/Public/TeamMember.hs | 7 +++- services/galley/src/Galley/API/Teams.hs | 36 +++++++++++-------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index 5c56816012..305c4fd9ed 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -20,6 +20,7 @@ module Galley.API.Public.TeamMember where import Galley.API.Teams import Galley.API.Teams.Export qualified as Export import Galley.App +import Imports import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Team.Collaborator @@ -36,7 +37,11 @@ teamMemberAPI = <@> mkNamedAPI @"update-team-member" updateTeamMember <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV <@> mkNamedAPI @"add-team-collaborator" - (\zuid tid (NewTeamCollaborator uid perms) -> createTeamCollaborator zuid uid tid perms) + ( \zuid tid (NewTeamCollaborator uid perms) -> do + n <- ensureNotTooLarge tid + ensureNotTooLargeForLegalHold tid (n.teamSize + n.apps + n.collaborators + 1) + createTeamCollaborator zuid uid tid perms + ) <@> mkNamedAPI @"get-team-collaborators" getAllTeamCollaborators <@> mkNamedAPI @"update-team-collaborator" updateTeamCollaborator <@> mkNamedAPI @"remove-team-collaborator" removeTeamCollaborator diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 59b86bf575..352ac5fa37 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -49,6 +49,7 @@ module Galley.API.Teams uncheckedUpdateTeamMember, userIsTeamOwner, canUserJoinTeam, + ensureNotTooLarge, ensureNotTooLargeForLegalHold, ensureNotTooLargeToActivateLegalHold, internalDeleteBindingTeam, @@ -1078,7 +1079,12 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do _ -> UserTypeFilterRegular pure case uType of UserTypeFilterRegular -> n {teamSize = n.teamSize + 1} - UserTypeFilterApp -> n {apps = n.apps + 1} + UserTypeFilterApp -> + -- FUTUREWORK: this shouldn't happen, apps are not team + -- members! See also: + -- https://wearezeta.atlassian.net/browse/WPB-28095 + -- https://wearezeta.atlassian.net/browse/WPB-25521 + n {apps = n.apps + 1} ensureNotTooLargeForLegalHold tid (sizeAfterAdd.teamSize + sizeAfterAdd.apps + sizeAfterAdd.collaborators) admins <- E.getTeamAdmins tid @@ -1105,20 +1111,20 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do APITeamQueue.pushTeamEvent tid e pure sizeAfterAdd - where - ensureNotTooLarge :: - ( Member E.BrigAPIAccess r, - Member (ErrorS 'TooManyTeamMembers) r, - Member (Input Opts) r - ) => - TeamId -> - Sem r TeamSize - ensureNotTooLarge teamid = do - o <- input - tSize <- E.getSize teamid - unless (teamSize tSize < fromIntegral (o ^. settings . maxTeamSize)) $ - throwS @'TooManyTeamMembers - pure tSize + +ensureNotTooLarge :: + ( Member E.BrigAPIAccess r, + Member (ErrorS 'TooManyTeamMembers) r, + Member (Input Opts) r + ) => + TeamId -> + Sem r TeamSize +ensureNotTooLarge teamid = do + o <- input + tSize <- E.getSize teamid + unless (teamSize tSize < fromIntegral (o ^. settings . maxTeamSize)) $ + throwS @'TooManyTeamMembers + pure tSize getBindingTeamMembers :: ( Member (ErrorS 'TeamNotFound) r, From 77f3240779e3362b41d30d6359207fe0cbf9753d Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 11:07:33 +0200 Subject: [PATCH 14/16] Refactor: move legalhold helpers to TeamSubsystem. --- .../API/Routes/Public/Galley/TeamMember.hs | 1 + .../wire-subsystems/src/Wire/TeamSubsystem.hs | 80 +++++++++++++++++++ .../galley/src/Galley/API/LegalHold/Team.hs | 37 +-------- .../src/Galley/API/Public/TeamMember.hs | 7 +- services/galley/src/Galley/API/Teams.hs | 49 +----------- 5 files changed, 93 insertions(+), 81 deletions(-) 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 33044bfcc0..e5d10d1097 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 @@ -213,6 +213,7 @@ type TeamMemberAPI = "add-team-collaborator" ( Summary "Add a collaborator to the team." :> From 'V10 + :> CanThrow 'TooManyTeamMembersOnTeamWithLegalhold :> ZLocalUser :> "teams" :> Capture "tid" TeamId diff --git a/libs/wire-subsystems/src/Wire/TeamSubsystem.hs b/libs/wire-subsystems/src/Wire/TeamSubsystem.hs index cd4fa9a7ca..8674077cde 100644 --- a/libs/wire-subsystems/src/Wire/TeamSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/TeamSubsystem.hs @@ -26,13 +26,22 @@ import Data.Qualified import Data.Range import Data.Singletons (Demote, Sing, SingKind, fromSing) import Imports +import Numeric.Natural import Polysemy +import Polysemy.Input (Input, input) import Wire.API.Error import Wire.API.Error.Galley +import Wire.API.Team.Feature (FeatureStatus (FeatureStatusEnabled), LegalholdConfig) +import Wire.API.Team.FeatureFlags (FanoutLimit, FeatureDefaults (..)) import Wire.API.Team.LegalHold (UserLegalHoldStatusResponse) import Wire.API.Team.Member import Wire.API.Team.Member.Error import Wire.API.Team.Member.Info (TeamMemberInfoList) +import Wire.API.Team.Size (TeamSize (..)) +import Wire.BrigAPIAccess (BrigAPIAccess, getSize) +import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getDbFeatureRawInternal) +import Wire.LegalHold (computeLegalHoldFeatureStatus) +import Wire.LegalHoldStore (LegalHoldStore) data PermissionCheckArgs teamAssociation where PermissionCheckArgs :: @@ -144,3 +153,74 @@ checkConsent :: Sem r ConsentGiven checkConsent teamsOfUsers other = do consentGiven <$> getLHStatus (Map.lookup other teamsOfUsers) other + +-- | Ensure that a team has fewer members than the given limit (usually +-- @settings.maxTeamSize@). Returns the team size as it was before adding +-- anybody. +ensureNotTooLarge :: + ( Member BrigAPIAccess r, + Member (ErrorS 'TooManyTeamMembers) r + ) => + Word32 -> + TeamId -> + Sem r TeamSize +ensureNotTooLarge maxSize tid = do + tSize <- getSize tid + unless (tSize.teamSize < fromIntegral maxSize) $ + throwS @'TooManyTeamMembers + pure tSize + +-- | Ensure that a team doesn't exceed the member count limit for the LegalHold +-- feature. A team with more members than the fanout limit is too large, because +-- the fanout limit would prevent turning LegalHold feature _off_ again (for +-- details see 'Galley.API.LegalHold.removeSettings'). +-- +-- If LegalHold is configured for whitelisted teams only we consider the team +-- size unlimited, because we make the assumption that these teams won't turn +-- LegalHold off after activation. +-- FUTUREWORK: Find a way around the fanout limit. +ensureNotTooLargeForLegalHold :: + forall r. + ( Member LegalHoldStore r, + Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r, + Member (Input FanoutLimit) r, + Member (Input (FeatureDefaults LegalholdConfig)) r, + Member FeaturesConfigSubsystem r + ) => + TeamId -> + Natural -> + Sem r () +ensureNotTooLargeForLegalHold tid teamSize = + whenM (isLegalHoldEnabledForTeam tid) $ + unlessM (teamSizeBelowLimit teamSize) $ + throwS @'TooManyTeamMembersOnTeamWithLegalhold + +isLegalHoldEnabledForTeam :: + forall r. + ( Member LegalHoldStore r, + Member FeaturesConfigSubsystem r, + Member (Input (FeatureDefaults LegalholdConfig)) r + ) => + TeamId -> + Sem r Bool +isLegalHoldEnabledForTeam tid = do + dbFeature <- getDbFeatureRawInternal tid + status <- computeLegalHoldFeatureStatus tid dbFeature + pure $ status == FeatureStatusEnabled + +teamSizeBelowLimit :: + ( Member (Input FanoutLimit) r, + Member (Input (FeatureDefaults LegalholdConfig)) r + ) => + Natural -> + Sem r Bool +teamSizeBelowLimit teamSize = do + limit <- fromIntegral . fromRange <$> input @FanoutLimit + let withinLimit = teamSize <= limit + featureLegalHold <- input @(FeatureDefaults LegalholdConfig) + case featureLegalHold of + FeatureLegalHoldDisabledPermanently -> pure withinLimit + FeatureLegalHoldDisabledByDefault -> pure withinLimit + FeatureLegalHoldWhitelistTeamsAndImplicitConsent -> + -- unlimited, see docs of 'ensureNotTooLargeForLegalHold' + pure True diff --git a/services/galley/src/Galley/API/LegalHold/Team.hs b/services/galley/src/Galley/API/LegalHold/Team.hs index 100089cb30..aa208bb531 100644 --- a/services/galley/src/Galley/API/LegalHold/Team.hs +++ b/services/galley/src/Galley/API/LegalHold/Team.hs @@ -28,12 +28,10 @@ where import Data.Code qualified as Code import Data.Id import Data.Misc (PlainTextPassword6) -import Data.Range import Imports -import Numeric.Natural import Polysemy import Polysemy.Error -import Polysemy.Input (Input, input) +import Polysemy.Input (Input) import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Team.Feature @@ -42,9 +40,10 @@ import Wire.API.Team.Size import Wire.API.User (VerificationAction) import Wire.API.User.Auth.ReAuth import Wire.BrigAPIAccess -import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getDbFeatureRawInternal) +import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem) import Wire.LegalHold import Wire.LegalHoldStore (LegalHoldStore) +import Wire.TeamSubsystem (isLegalHoldEnabledForTeam, teamSizeBelowLimit) assertLegalHoldEnabledForTeam :: forall r. @@ -59,19 +58,6 @@ assertLegalHoldEnabledForTeam tid = unlessM (isLegalHoldEnabledForTeam tid) $ throwS @'LegalHoldNotEnabled -isLegalHoldEnabledForTeam :: - forall r. - ( Member LegalHoldStore r, - Member FeaturesConfigSubsystem r, - Member (Input (FeatureDefaults LegalholdConfig)) r - ) => - TeamId -> - Sem r Bool -isLegalHoldEnabledForTeam tid = do - dbFeature <- getDbFeatureRawInternal tid - status <- computeLegalHoldFeatureStatus tid dbFeature - pure $ status == FeatureStatusEnabled - ensureNotTooLargeToActivateLegalHold :: ( Member BrigAPIAccess r, Member (ErrorS 'CannotEnableLegalHoldServiceLargeTeam) r, @@ -85,23 +71,6 @@ ensureNotTooLargeToActivateLegalHold tid = do unlessM (teamSizeBelowLimit tSize) $ throwS @'CannotEnableLegalHoldServiceLargeTeam -teamSizeBelowLimit :: - ( Member (Input FanoutLimit) r, - Member (Input (FeatureDefaults LegalholdConfig)) r - ) => - Natural -> - Sem r Bool -teamSizeBelowLimit teamSize = do - limit <- fromIntegral . fromRange <$> input @FanoutLimit - let withinLimit = teamSize <= limit - featureLegalHold <- input @(FeatureDefaults LegalholdConfig) - case featureLegalHold of - FeatureLegalHoldDisabledPermanently -> pure withinLimit - FeatureLegalHoldDisabledByDefault -> pure withinLimit - FeatureLegalHoldWhitelistTeamsAndImplicitConsent -> - -- unlimited, see docs of 'ensureNotTooLargeForLegalHold' - pure True - ensureReAuthorised :: ( Member BrigAPIAccess r, Member (Error AuthenticationError) r diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index 305c4fd9ed..87b467cbf0 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -24,7 +24,10 @@ import Imports import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Team.Collaborator +import Wire.API.Team.Size +import Wire.BrigAPIAccess (getSize) import Wire.TeamCollaboratorsSubsystem +import Wire.TeamSubsystem qualified as TeamSubsystem teamMemberAPI :: API TeamMemberAPI GalleyEffects teamMemberAPI = @@ -38,8 +41,8 @@ teamMemberAPI = <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV <@> mkNamedAPI @"add-team-collaborator" ( \zuid tid (NewTeamCollaborator uid perms) -> do - n <- ensureNotTooLarge tid - ensureNotTooLargeForLegalHold tid (n.teamSize + n.apps + n.collaborators + 1) + n <- getSize tid + TeamSubsystem.ensureNotTooLargeForLegalHold tid (n.teamSize + n.apps + n.collaborators + 1) createTeamCollaborator zuid uid tid perms ) <@> mkNamedAPI @"get-team-collaborators" getAllTeamCollaborators diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 352ac5fa37..50e98b84b1 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -49,8 +49,6 @@ module Galley.API.Teams uncheckedUpdateTeamMember, userIsTeamOwner, canUserJoinTeam, - ensureNotTooLarge, - ensureNotTooLargeForLegalHold, ensureNotTooLargeToActivateLegalHold, internalDeleteBindingTeam, updateTeamCollaborator, @@ -80,7 +78,6 @@ import Galley.API.Teams.Notifications qualified as APITeamQueue import Galley.App import Galley.Types.Error as Galley import Imports hiding (forkIO) -import Numeric.Natural import Polysemy import Polysemy.Error import Polysemy.Input @@ -1021,31 +1018,6 @@ ensureNotElevated targetPermissions member = ) $ throwS @'InvalidPermissions --- | Ensure that a team doesn't exceed the member count limit for the LegalHold --- feature. A team with more members than the fanout limit is too large, because --- the fanout limit would prevent turning LegalHold feature _off_ again (for --- details see 'Galley.API.LegalHold.removeSettings'). --- --- If LegalHold is configured for whitelisted teams only we consider the team --- size unlimited, because we make the assumption that these teams won't turn --- LegalHold off after activation. --- FUTUREWORK: Find a way around the fanout limit. -ensureNotTooLargeForLegalHold :: - forall r. - ( Member LegalHoldStore r, - Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r, - Member (Input FanoutLimit) r, - Member (Input (FeatureDefaults LegalholdConfig)) r, - Member FeaturesConfigSubsystem r - ) => - TeamId -> - Natural -> - Sem r () -ensureNotTooLargeForLegalHold tid teamSize = - whenM (isLegalHoldEnabledForTeam tid) $ - unlessM (teamSizeBelowLimit teamSize) $ - throwS @'TooManyTeamMembersOnTeamWithLegalhold - addTeamMemberInternal :: ( Member E.BrigAPIAccess r, Member (ErrorS 'TooManyTeamMembers) r, @@ -1072,7 +1044,8 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do Log.field "targets" (toByteString (new ^. userId)) . Log.field "action" (Log.val "Teams.addTeamMemberInternal") sizeAfterAdd <- do - n <- ensureNotTooLarge tid + maxSize <- inputs @Opts (^. settings . maxTeamSize) + n <- TeamSubsystem.ensureNotTooLarge maxSize tid uType <- E.getUser (new ^. userId) <&> \case Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp @@ -1085,7 +1058,7 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do -- https://wearezeta.atlassian.net/browse/WPB-28095 -- https://wearezeta.atlassian.net/browse/WPB-25521 n {apps = n.apps + 1} - ensureNotTooLargeForLegalHold tid (sizeAfterAdd.teamSize + sizeAfterAdd.apps + sizeAfterAdd.collaborators) + TeamSubsystem.ensureNotTooLargeForLegalHold tid (sizeAfterAdd.teamSize + sizeAfterAdd.apps + sizeAfterAdd.collaborators) admins <- E.getTeamAdmins tid let admins' = [new ^. userId | isAdminOrOwner (new ^. M.permissions)] <> admins @@ -1112,20 +1085,6 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do APITeamQueue.pushTeamEvent tid e pure sizeAfterAdd -ensureNotTooLarge :: - ( Member E.BrigAPIAccess r, - Member (ErrorS 'TooManyTeamMembers) r, - Member (Input Opts) r - ) => - TeamId -> - Sem r TeamSize -ensureNotTooLarge teamid = do - o <- input - tSize <- E.getSize teamid - unless (teamSize tSize < fromIntegral (o ^. settings . maxTeamSize)) $ - throwS @'TooManyTeamMembers - pure tSize - getBindingTeamMembers :: ( Member (ErrorS 'TeamNotFound) r, Member (ErrorS 'NonBindingTeam) r, @@ -1167,7 +1126,7 @@ canUserJoinTeam tid = do when lhEnabled $ do sizeBeforeJoin <- E.getSize tid let sizeAfterJoin = sizeBeforeJoin {teamSize = sizeBeforeJoin.teamSize + 1} - ensureNotTooLargeForLegalHold tid (sizeAfterJoin.teamSize + sizeAfterJoin.apps + sizeAfterJoin.collaborators) + TeamSubsystem.ensureNotTooLargeForLegalHold tid (sizeAfterJoin.teamSize + sizeAfterJoin.apps + sizeAfterJoin.collaborators) -- | Modify and get visibility type for a team (internal, no user permission checks) getSearchVisibilityInternal :: From fd87357ec1d16e5b397b80f6a5414a981a3a37d1 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 11:18:44 +0200 Subject: [PATCH 15/16] Fix: team size query in ES. --- .../src/Wire/IndexedUserStore/ElasticSearch.hs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index 1aab52f3b2..68e0f8a864 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -119,13 +119,18 @@ getTeamSizeImpl cfg tid = do { ES.boolQueryMustMatch = [teamQ, termQ "type" "app"] } + -- Collaborators are not members of the team, they are users (of other teams + -- or of no team) that collaborate with it. + collaboratorQuery = termQ "collaborating_teams" (idToText tid) + search = (ES.mkSearch Nothing Nothing) { ES.size = ES.Size 0, ES.aggBody = Just $ - ES.mkAggregations "regulars" (ES.FilterAgg (ES.FilterAggregation (ES.Filter regularQuery) Nothing)) + ES.mkAggregations "teamSize" (ES.FilterAgg (ES.FilterAggregation (ES.Filter regularQuery) Nothing)) <> ES.mkAggregations "apps" (ES.FilterAgg (ES.FilterAggregation (ES.Filter appQuery) Nothing)) + <> ES.mkAggregations "collaborators" (ES.FilterAgg (ES.FilterAggregation (ES.Filter collaboratorQuery) Nothing)) } upsertImpl :: From 7a33606168baa79c839b7602610ec94a4271fad4 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 10:56:00 +0200 Subject: [PATCH 16/16] Polish swagger docs. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/wire-api/src/Wire/API/Team/Size.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-api/src/Wire/API/Team/Size.hs b/libs/wire-api/src/Wire/API/Team/Size.hs index ba84e40039..8646773707 100644 --- a/libs/wire-api/src/Wire/API/Team/Size.hs +++ b/libs/wire-api/src/Wire/API/Team/Size.hs @@ -39,7 +39,7 @@ data TeamSize = TeamSize instance ToSchema TeamSize where schema = - objectWithDocModifier (description ?~ "Number of team members (paid seats, always regular users), appps, collaborators.") $ + objectWithDocModifier (description ?~ "Team member counts: paid seats (regular users), apps, and collaborators.") $ TeamSize <$> (.teamSize) .= field "teamSize" schema <*> (.apps) .= field "apps" schema