Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
`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 # (or postgresql, migration-to-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)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Treat team collaborators like team members in contact search.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Changes that are invisible to the user record now advance the search index
version, so the updated document is no longer dropped as a version conflict:
adding, changing or removing a team collaboration, and removing a user's SSO
identity. Previously the collaborating teams, resp. the SSO identity, could
stay in the index indefinitely.
Cancelling a pending email change did not trigger a re-index at all; it now
does, so the unvalidated email address no longer stays in the index either.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
brig-index: tolerate accounts whose team member entry is already gone
(eg. during team deletion), but keep failing the affected page if
galley itself errors out.
118 changes: 118 additions & 0 deletions integration/test/Test/TeamCollaborators.hs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{-# OPTIONS_GHC -Wno-ambiguous-fields #-}

-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2025 Wire Swiss GmbH <opensource@wire.com>
Expand All @@ -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
Expand Down Expand Up @@ -317,3 +322,116 @@ 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]

-- 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
-- NB: this second change to multiCollab's collaborations, like the removals
-- above, only reaches the index because repeating a write of the value that
-- is already stored still produces a fresh cassandra writetime -- that is what
-- advances the document version (see 'Wire.UserStore.BumpWriteTime'). Without
-- it the updated document would be dropped as a version conflict, so these
-- steps are the only coverage that mechanism has: please do not collapse them.
addTeamCollaborator otherOwner otherTeam multiCollab ["implicit_connection"] >>= assertSuccess

for_ [owner, alice] $ assertFinds multiCollabName [multiCollab]
for_ [otherOwner, bob] $ assertFinds multiCollabName [multiCollab]

-- Dropping one of the two collaborations leaves the other one in the index.
removeTeamCollaborator owner team multiCollab >>= assertSuccess

for_ [owner, alice] $ assertFinds multiCollabName ([] @Value)
for_ [otherOwner, bob] $ assertFinds multiCollabName [multiCollab]
15 changes: 15 additions & 0 deletions libs/wire-api/src/Wire/API/Routes/Internal/Brig/SearchIndex.hs
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,18 @@ type ISearchIndexAPI =
:> Capture "userId" UserId
:> Post '[JSON] NoContent
)
:<|> Named
"bump-write-time-and-update-search-index"
( Summary "updates the search index for a single user, forcing the document version to advance"
:> Description
"Use this instead of `update-search-index` when the change that needs to be \
\indexed does not live in the user record itself (currently: team \
\collaborations). The index version is derived from the user record, so \
\without bumping it the updated document would be rejected as a version \
\conflict."
:> "index"
:> "update"
:> Capture "userId" UserId
:> "bump-write-time"
:> Post '[JSON] NoContent
)
2 changes: 2 additions & 0 deletions libs/wire-subsystems/src/Wire/BrigAPIAccess.hs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ data BrigAPIAccess m a where
GetUserExportData :: UserId -> BrigAPIAccess m (Maybe TeamExportUser)
DeleteBot :: ConvId -> BotId -> BrigAPIAccess m ()
UpdateSearchIndex :: UserId -> BrigAPIAccess m ()
-- | See 'Wire.UserSubsystem.InternalBumpWriteTimeAndUpdateSearchIndex'.
BumpWriteTimeAndUpdateSearchIndex :: UserId -> BrigAPIAccess m ()
GetAccountsBy :: GetBy -> BrigAPIAccess m [User]
GetUsersByVariousKeys :: [UserId] -> [Handle] -> [EmailAddress] -> HavePendingInvitations -> BrigAPIAccess m [User]
CreateGroupInternal :: ManagedBy -> TeamId -> Maybe UserId -> NewUserGroup -> BrigAPIAccess m (Either Wai.Error UserGroup)
Expand Down
66 changes: 66 additions & 0 deletions libs/wire-subsystems/src/Wire/BrigAPIAccess/Local.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2026 Wire Swiss GmbH <opensource@wire.com>
--
-- 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 <https://www.gnu.org/licenses/>.

-- | 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 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 selfEndpoint runUser = interpret $ \case
UpdateSearchIndex uid -> runUser (UserSubsystem.internalUpdateSearchIndex uid)
BumpWriteTimeAndUpdateSearchIndex uid -> runUser (UserSubsystem.internalBumpWriteTimeAndUpdateSearchIndex uid)
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)
Loading