From 4c2fcc86d90e46d7bd45b0e546833075af2ffd74 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 00000000000..d66924aaeea --- /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 ea1f73fdcb700461834f148f7c0ceec13fb3c5cc 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 cf55c3a558e..55a84974012 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 affd45aabffa49223a496b0f0ec026c781e48ea6 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 e1dd13ff30f..2cac7678e79 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 6317ed7ba2d..2142d93aaa4 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 156f8f6e479..ec3fc34aeb5 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 30a970706e3..af094594aba 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 5e8dcac765e..58ab0de81e3 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 09ac630d191..1d54a2da1c7 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 0f5f428ae73..5988c585733 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 d5cb2dfee62..d766491d851 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 786f733240f..043f3a834db 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 a09d56bd8ff..413c09d0bd1 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 68942a77cb3..faca7078802 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 86f9f2e9b2d..13e54c17856 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 10c4a949c2b..24eaa9db7e9 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 33d49e2ba3d022bd0585023f8f163277da5fa062 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 2cac7678e79..e1dd13ff30f 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 c23b679ed72..72a79fec9cf 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 00000000000..57af12fc3ce --- /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 2142d93aaa4..e285ee4ad20 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 ec3fc34aeb5..07572a29851 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 fcdf0731b08..ebf79c96721 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 a6a1e968a72..b898ae69b51 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 af094594aba..ab2d6360b4c 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 58ab0de81e3..5464dae2a8f 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 1d54a2da1c7..b051132f1ed 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 5988c585733..0f5f428ae73 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 d766491d851..f78029cebde 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 1324d919db3..ff27a27fa7f 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 4630c0c7f77..ea57140aad5 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 00000000000..5aacaddac97 --- /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 4def51eeef7..63a334527ab 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 6861f097797..f623e0012dd 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 413c09d0bd1..0a18b9d62fb 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 faca7078802..9e309d50dda 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 f7627036f1c..42196d6e18c 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 0d367f19eed..66dbee09c56 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 13e54c17856..86f9f2e9b2d 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 24eaa9db7e9..10c4a949c2b 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 f866fc5a9ca..6c1c87b757e 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 ea72f9aeef5..72e768d0cf1 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 4c4919729d9..68a17f07b1f 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 4d755d8c612..f47dc7f3798 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 c85344ca4101de2eb44ac0f19edf93d52ee5d310 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 ab2d6360b4c..bb0541636b0 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 c0525b6d92de16677955e12785a5975d0865c555 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 72a79fec9cf..c23b679ed72 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 0a7ee78070f1da80412771244393f3ba8f0ba0fa 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 57af12fc3ce..43f04b0175c 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 e42a4791392..b96d0abeea0 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 6c2145ea8cd..27812d06b47 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 6c1c87b757e..4414567c910 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 15964d7730cc9a298a58d3e2542c8e11fab9dc8e 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 55a84974012..642dad537e8 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 2b0c8f1826558fb957f9d09f944862c8c71e5d05 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 72e768d0cf1..d703437a3ad 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 590d8dfd7f745e30385ef65c1cbf47ba8409d76c Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 28 Aug 2026 12:10:48 +0200 Subject: [PATCH 10/16] Fix brig-index: do not bump index version in failure case. --- .../IndexedUserStore/Bulk/ElasticSearch.hs | 45 +++++++++++++------ .../src/Wire/UserSearch/Migration.hs | 1 + services/brig/src/Brig/Index/Eval.hs | 13 +++++- 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs index e285ee4ad20..31d77779011 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/Bulk/ElasticSearch.hs @@ -59,20 +59,26 @@ type IOInterpreter r = forall a. Sem r a -> IO a expectedMigrationVersion :: MigrationVersion expectedMigrationVersion = MigrationVersion 7 -syncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore 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 Int syncAllUsers interpreter pageSize = syncAllUsersWithVersion interpreter pageSize ES.ExternalGT -forceSyncAllUsers :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore 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 Int forceSyncAllUsers interpreter pageSize = syncAllUsersWithVersion interpreter pageSize ES.ExternalGTE -syncAllUsersWithVersion :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> (ES.ExternalDocVersion -> ES.VersionControl) -> IO () +-- | Returns the number of users that could not be indexed because some of the +-- data needed to build their document was unavailable. Those users have been +-- logged individually by 'logAndHush'. +syncAllUsersWithVersion :: (Member UserStore r, Member IndexedUserStore r, Member TinyLog r, Member GalleyAPIAccess r, Member TeamCollaboratorsStore r) => IOInterpreter r -> Int32 -> (ES.ExternalDocVersion -> ES.VersionControl) -> IO Int syncAllUsersWithVersion interpreter pageSize mkVersion = - runConduit $ + fmap getSum . runConduit $ zipSources (CL.sourceList [1 ..]) (paginateWithStateC (interpreter . getIndexUsersPaginated pageSize)) .| logPage .| mkUserDocs - .| Conduit.mapM_ (interpreter . IndexedUserStore.bulkUpsert) + .| Conduit.foldMapM upsertPage where + upsertPage :: (Int, [(ES.DocId, UserDoc, ES.VersionControl)]) -> IO (Sum Int) + upsertPage (skipped, docs) = Sum skipped <$ interpreter (IndexedUserStore.bulkUpsert docs) + logPage :: ConduitT (Int32, [IndexUser]) [IndexUser] IO () logPage = Conduit.mapM $ \(pageNumber, page) -> do interpreter $ @@ -82,7 +88,9 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = . Log.field "firstUser" (maybe "N/A" (idToText . (.userId)) (headMay page)) pure page - mkUserDocs :: ConduitT [IndexUser] [(ES.DocId, UserDoc, ES.VersionControl)] IO () + -- Emits the documents to be indexed together with the number of users of + -- this page that had to be skipped. + mkUserDocs :: ConduitT [IndexUser] (Int, [(ES.DocId, UserDoc, ES.VersionControl)]) IO () mkUserDocs = Conduit.mapM $ \page -> do -- FUTUREWORK: extract team visibilities, roles and user type -- more efficiently sending one query per page @@ -91,10 +99,10 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = -- contains User, Maybe Role, UserType, ..., and pass around -- ExtendedUser. this should make the code less convoluted. - let teams :: Map TeamId [IndexUser] = Map.fromListWith (<>) $ mapMaybe (\u -> (,[u]) <$> u.teamId) page - teamIds = Map.keys teams + let teams :: Map TeamId [IndexUser] + teams = Map.fromListWith (<>) $ mapMaybe (\u -> (,[u]) <$> u.teamId) page - visMap <- fmap Map.fromList . pooledForConcurrentlyN 16 teamIds $ \t -> do + visMap <- fmap Map.fromList . pooledForConcurrentlyN 16 (Map.keys teams) $ \t -> do x <- try $ interpreter $ teamSearchVisibilityInbound t pure (t, x) @@ -139,8 +147,12 @@ syncAllUsersWithVersion interpreter pageSize mkVersion = roleWithTime <- sequence (Map.lookup u.userId roles) pure . mkVersion . ES.ExternalDocVersion . docVersion $ indexUserToVersion roleWithTime u - let docsWithErrors = map (\u -> (userIdToDocId u.userId, mkUserDoc u, mkDocVersion u)) page - interpreter . flip mapMaybeM docsWithErrors $ logAndHush + docsWithErrors :: (e ~ Either SomeException) => [(ES.DocId, e UserDoc, e ES.VersionControl)] + docsWithErrors = map (\u -> (userIdToDocId u.userId, mkUserDoc u, mkDocVersion u)) page + + docs <- interpreter . flip mapMaybeM docsWithErrors $ logAndHush + let skipped = length docsWithErrors - length docs + pure (skipped, docs) rightSecond :: (a, b) -> (a, Either c b) rightSecond (a, b) = (a, Right b) @@ -184,8 +196,15 @@ migrateData interpreter pageSize = interpreter $ do Log.msg (Log.val "Migration necessary.") . Log.field "expectedVersion" expectedMigrationVersion . Log.field "foundVersion" foundVersion - embed $ forceSyncAllUsers interpreter pageSize - MigrationStore.persistMigrationVersion expectedMigrationVersion + skipped <- embed $ forceSyncAllUsers interpreter pageSize + if skipped == 0 + then MigrationStore.persistMigrationVersion expectedMigrationVersion + else do + Log.err $ + Log.msg (Log.val "Migration incomplete, not persisting migration version.") + . Log.field "expectedVersion" expectedMigrationVersion + . Log.field "skippedUsers" skipped + throw $ SyncIncomplete else do Log.info $ Log.msg (Log.val "No migration necessary.") diff --git a/libs/wire-subsystems/src/Wire/UserSearch/Migration.hs b/libs/wire-subsystems/src/Wire/UserSearch/Migration.hs index 817d10370e9..7cbbbcd734e 100644 --- a/libs/wire-subsystems/src/Wire/UserSearch/Migration.hs +++ b/libs/wire-subsystems/src/Wire/UserSearch/Migration.hs @@ -42,6 +42,7 @@ data MigrationException | PutMappingFailed String | TargetIndexAbsent | VersionSourceMissing (ES.SearchResult MigrationVersion) + | SyncIncomplete deriving (Show) instance Exception MigrationException diff --git a/services/brig/src/Brig/Index/Eval.hs b/services/brig/src/Brig/Index/Eval.hs index d703437a3ad..695007e195e 100644 --- a/services/brig/src/Brig/Index/Eval.hs +++ b/services/brig/src/Brig/Index/Eval.hs @@ -176,10 +176,14 @@ runCommand l = \case runIndexIO e $ resetIndex (mkCreateIndexSettings es) Reindex es cas pg userStorageLocation galley pageSize -> do semDeps <- mkSemDeps (es ^. esConnection) cas pg l - IndexedUserStoreBulk.syncAllUsers (runSem semDeps userStorageLocation galley l) pageSize + skipped <- IndexedUserStoreBulk.syncAllUsers (runSem semDeps userStorageLocation galley l) pageSize + when (skipped /= 0) do + throwM . IndexMigrationError $ "Reindex: failed to sync " <> show skipped <> " documents." ReindexSameOrNewer es cas pg userStorageLocation galley pageSize -> do semDeps <- mkSemDeps (es ^. esConnection) cas pg l - IndexedUserStoreBulk.forceSyncAllUsers (runSem semDeps userStorageLocation galley l) pageSize + skipped <- IndexedUserStoreBulk.forceSyncAllUsers (runSem semDeps userStorageLocation galley l) pageSize + when (skipped /= 0) do + throwM . IndexMigrationError $ "ReindexSameOrNewer: failed to sync " <> show skipped <> " documents." UpdateMapping esConn galley -> do e <- initIndex l esConn galley runIndexIO e updateMapping @@ -260,6 +264,11 @@ waitForTaskToComplete timeoutSeconds taskNodeId = do errTaskGet :: ES.EsError -> m x errTaskGet e = throwM $ ReindexFromAnotherIndexError $ "Error response while getting task: " <> show e +newtype IndexMigrationError = IndexMigrationError String + deriving (Show) + +instance Exception IndexMigrationError + newtype ReindexFromAnotherIndexError = ReindexFromAnotherIndexError String deriving (Show) From 0bfdbe6bd6e4e645a52a51663c8d5212ccc9c316 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 28 Aug 2026 12:35:20 +0200 Subject: [PATCH 11/16] Add release notes on required postgres setup steps. --- ...rators-like-team-members-in-contact-search | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 changelog.d/0-release-notes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search diff --git a/changelog.d/0-release-notes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search b/changelog.d/0-release-notes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search new file mode 100644 index 00000000000..33e71feaf99 --- /dev/null +++ b/changelog.d/0-release-notes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search @@ -0,0 +1,24 @@ +`GET /contacts/search` returns apps (and regular users) that collaborate with the searcher's team. + +This means that `brig-index-migrate-data` now requires you to +configure the `elasticsearch-index` chart's postgres setup, and have a +postgres instance reachable with that setup, eg., like this: + +``` +# in charts/elasticsearch-index/values.yaml +postgresql: + host: postgresql # DNS name without protocol + port: "5432" + user: wire-server + dbname: wire-server +postgresqlPool: + size: 100 + acquisitionTimeout: 10s + idlenessTimeout: 10m + +postgresMigration: + user: cassandra +``` + +[More +info](https://docs.wire.com/latest/developer/reference/config-options.html?h=config#configure-postgresql) From 6a744ef41149ab1adedb71802d95693aa24f1cea Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 28 Aug 2026 14:19:13 +0200 Subject: [PATCH 12/16] Update libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs Co-authored-by: Gautier DI FOLCO --- libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs index 43f04b0175c..d89904b0a8e 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs @@ -1,6 +1,6 @@ -- This file is part of the Wire Server implementation. -- --- Copyright (C) 2025 Wire Swiss GmbH +-- Copyright (C) 2026 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 From c6208202e9b6893b0510ebcab78e6da3a1ff0e9f Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 28 Aug 2026 14:19:22 +0200 Subject: [PATCH 13/16] Update libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs Co-authored-by: Gautier DI FOLCO --- .../test/unit/Wire/MockInterpreters/BrigAPIAccess.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs index 5aacaddac97..4ee30b8cae0 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs @@ -1,6 +1,6 @@ -- This file is part of the Wire Server implementation. -- --- Copyright (C) 2025 Wire Swiss GmbH +-- Copyright (C) 2026 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 From 395d7ed3ad2ce9a96a1dff7ff6c3051e201c8477 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 28 Aug 2026 15:57:53 +0200 Subject: [PATCH 14/16] Polish release notes. --- ...ollaborators-like-team-members-in-contact-search | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/changelog.d/0-release-notes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search b/changelog.d/0-release-notes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search index 33e71feaf99..3caefeb19af 100644 --- a/changelog.d/0-release-notes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search +++ b/changelog.d/0-release-notes/WPB-28089-treat-team-collaborators-like-team-members-in-contact-search @@ -5,7 +5,7 @@ configure the `elasticsearch-index` chart's postgres setup, and have a postgres instance reachable with that setup, eg., like this: ``` -# in charts/elasticsearch-index/values.yaml +# in (charts/elasticsearch-index/)values.yaml postgresql: host: postgresql # DNS name without protocol port: "5432" @@ -17,8 +17,13 @@ postgresqlPool: idlenessTimeout: 10m postgresMigration: - user: cassandra + user: cassandra # (or postgresql, migration-to-postgresql, ...) ``` -[More -info](https://docs.wire.com/latest/developer/reference/config-options.html?h=config#configure-postgresql) +Notes: +- If you have experienced any elasticsearch index update issues since 2026-03-24 (Chart Release 5.29.0), this might be related. If you have not resolved them, consider updating your `values.yaml` now and running a full re-index. +- Note: `brig-index` understands `--user-storage-location`, but that is not relevant here, because collaborators are not technically user accounts, but pointers to user accounts, and they are stored in a different table. + +More info: +- [configuring postgres](https://docs.wire.com/latest/developer/reference/config-options.html?h=config#configure-postgresql) +- [maintaining elastic search](https://docs.wire.com/latest/developer/reference/elastic-search.html?h=elasticsearch) From 0d9c82274907a7960c48f9faba9898fd01acec14 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 28 Aug 2026 16:14:04 +0200 Subject: [PATCH 15/16] Remove vaguely ominous source comment. This has been added in baf4fbc502; after it had been touched several times since its birth: the old bytestring didn't fit what ES did any more. I can't think of a way in which this comment may be helpful, so I've removed it. --- libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs index 0a18b9d62fb..ea721887393 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSearch/TypesSpec.hs @@ -69,6 +69,5 @@ userDoc1 = udType = Nothing } --- Dont touch this. This represents serialized legacy data. userDoc1ByteString :: LByteString 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\"}" From 3a0f3cfec10e87619c496209569f9d3918d461ca Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 28 Aug 2026 16:18:45 +0200 Subject: [PATCH 16/16] Don't hide actual desired behavior... ... in a long list of "not implemented" errors. Without this interpreter or something analogous, some tests in Wire.TeamCollaboratorsSubsystem.InterpreterSpec will fail. --- .../test/unit/Wire/MockInterpreters/BrigAPIAccess.hs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs index 4ee30b8cae0..aeb76299093 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs @@ -24,6 +24,8 @@ import Wire.BrigAPIAccess -- | Errors out on everything except 'UpdateSearchIndex', which is a no-op. mockBrigAPIAccess :: InterpreterFor BrigAPIAccess r mockBrigAPIAccess = interpret $ \case + UpdateSearchIndex _ -> pure () + -- everything else is not implemented GetConnectionsUnqualified {} -> error "GetConnectionsUnqualified: implement on demand (mockBrigAPIAccess)" GetConnections {} -> error "GetConnections: implement on demand (mockBrigAPIAccess)" PutConnectionInternal {} -> error "PutConnectionInternal: implement on demand (mockBrigAPIAccess)" @@ -45,7 +47,6 @@ mockBrigAPIAccess = interpret $ \case 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)"