diff --git a/changelog.d/2-features/WPB-5105 b/changelog.d/2-features/WPB-5105 index a57f38f2be..933b56ee00 100644 --- a/changelog.d/2-features/WPB-5105 +++ b/changelog.d/2-features/WPB-5105 @@ -1,3 +1,4 @@ Allowlist for who on cloud can connect to on-prem: - Internal API to configure allowlist -(#3697) +- Restrict federated user search according to team federation policy +(#3697, #3732) diff --git a/integration/integration.cabal b/integration/integration.cabal index e31f550f90..df86cf5024 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -120,6 +120,7 @@ library Test.Notifications Test.Presence Test.Roles + Test.Search Test.User Testlib.App Testlib.Assertions diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs index 6a4ae4d6e2..28267ac3a5 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -54,7 +54,7 @@ createUser domain cu = do data FedConn = FedConn { domain :: String, searchStrategy :: String, - restriction :: String + restriction :: Maybe [String] } deriving (Eq, Ord, Show) @@ -63,7 +63,13 @@ instance ToJSON FedConn where Aeson.object [ "domain" .= d, "search_policy" .= s, - "restriction" .= r + "restriction" + .= maybe + (Aeson.object ["tag" .= "allow_all", "value" .= Aeson.Null]) + ( \teams -> + Aeson.object ["tag" .= "restrict_by_team", "value" .= Aeson.toJSON teams] + ) + r ] instance MakesValue FedConn where @@ -159,11 +165,14 @@ connectWithRemoteUser userFrom userTo = do addFederationRemoteTeam :: (HasCallStack, MakesValue domain, MakesValue remoteDomain, MakesValue team) => domain -> remoteDomain -> team -> App () addFederationRemoteTeam domain remoteDomain team = do + void $ addFederationRemoteTeam' domain remoteDomain team >>= getBody 200 + +addFederationRemoteTeam' :: (HasCallStack, MakesValue domain, MakesValue remoteDomain, MakesValue team) => domain -> remoteDomain -> team -> App Response +addFederationRemoteTeam' domain remoteDomain team = do d <- asString remoteDomain t <- make team req <- baseRequest domain Brig Unversioned $ joinHttpPath ["i", "federation", "remotes", d, "teams"] - res <- submit "POST" (req & addJSONObject ["team_id" .= t]) - res.status `shouldMatchInt` 200 + submit "POST" (req & addJSONObject ["team_id" .= t]) getFederationRemoteTeams :: (HasCallStack, MakesValue domain, MakesValue remoteDomain) => domain -> remoteDomain -> App Response getFederationRemoteTeams domain remoteDomain = do diff --git a/integration/test/API/GalleyInternal.hs b/integration/test/API/GalleyInternal.hs index 428199004e..f683e41a63 100644 --- a/integration/test/API/GalleyInternal.hs +++ b/integration/test/API/GalleyInternal.hs @@ -37,6 +37,13 @@ getTeamFeature domain_ featureName tid = do req <- baseRequest domain_ Galley Unversioned $ joinHttpPath ["i", "teams", tid, "features", featureName] submit "GET" $ req +setTeamFeatureStatus :: (HasCallStack, MakesValue domain, MakesValue team) => domain -> team -> String -> String -> App () +setTeamFeatureStatus domain team featureName status = do + tid <- asString team + req <- baseRequest domain Galley Unversioned $ joinHttpPath ["i", "teams", tid, "features", featureName] + res <- submit "PATCH" $ req & addJSONObject ["status" .= status] + res.status `shouldMatchInt` 200 + getFederationStatus :: ( HasCallStack, MakesValue user diff --git a/integration/test/Test/Brig.hs b/integration/test/Test/Brig.hs index cd06769965..f35fbd1856 100644 --- a/integration/test/Test/Brig.hs +++ b/integration/test/Test/Brig.hs @@ -2,8 +2,7 @@ module Test.Brig where import API.Brig qualified as BrigP import API.BrigInternal qualified as BrigI -import API.Common qualified as API -import API.GalleyInternal qualified as GalleyI +import API.Common (randomName) import Data.Aeson.Types hiding ((.=)) import Data.Set qualified as Set import Data.String.Conversions @@ -14,17 +13,6 @@ import SetupHelpers import Testlib.Assertions import Testlib.Prelude -testSearchContactForExternalUsers :: HasCallStack => App () -testSearchContactForExternalUsers = do - owner <- randomUser OwnDomain def {BrigI.team = True} - partner <- randomUser OwnDomain def {BrigI.team = True} - - bindResponse (GalleyI.putTeamMember partner (partner %. "team") (API.teamRole "partner")) $ \resp -> - resp.status `shouldMatchInt` 200 - - bindResponse (BrigP.searchContacts partner (owner %. "name") OwnDomain) $ \resp -> - resp.status `shouldMatchInt` 403 - testCrudFederationRemotes :: HasCallStack => App () testCrudFederationRemotes = do otherDomain <- asString OtherDomain @@ -54,11 +42,11 @@ testCrudFederationRemotes = do dom1 :: String <- (<> ".example.com") . UUID.toString <$> liftIO UUID.nextRandom let remote1, remote1' :: BrigI.FedConn - remote1 = BrigI.FedConn dom1 "no_search" "allow_all" - remote1' = remote1 {BrigI.searchStrategy = "full_search", BrigI.restriction = "restrict_by_team"} + remote1 = BrigI.FedConn dom1 "no_search" Nothing + remote1' = remote1 {BrigI.searchStrategy = "full_search", BrigI.restriction = Just []} cfgRemotesExpect :: BrigI.FedConn - cfgRemotesExpect = BrigI.FedConn (cs otherDomain) "full_search" "allow_all" + cfgRemotesExpect = BrigI.FedConn (cs otherDomain) "full_search" Nothing cfgRemotes <- parseFedConns =<< BrigI.readFedConns ownDomain cfgRemotes `shouldMatch` ([] @Value) @@ -139,46 +127,17 @@ testSwagger = do resp.status `shouldMatchInt` 200 void resp.json -testRemoteUserSearch :: HasCallStack => App () -testRemoteUserSearch = do - startDynamicBackends [def, def] $ \[d1, d2] -> do - void $ BrigI.createFedConn d2 (BrigI.FedConn d1 "full_search" "allow_all") - - u1 <- randomUser d1 def - u2 <- randomUser d2 def - BrigI.refreshIndex d2 - uidD2 <- objId u2 - - bindResponse (BrigP.searchContacts u1 (u2 %. "name") d2) $ \resp -> do - resp.status `shouldMatchInt` 200 - docs <- resp.json %. "documents" >>= asList - case docs of - [] -> assertFailure "Expected a non empty result, but got an empty one" - doc : _ -> doc %. "id" `shouldMatch` uidD2 - -testRemoteUserSearchExactHandle :: HasCallStack => App () -testRemoteUserSearchExactHandle = do - startDynamicBackends [def, def] $ \[d1, d2] -> do - void $ BrigI.createFedConn d2 (BrigI.FedConn d1 "exact_handle_search" "allow_all") - - u1 <- randomUser d1 def - u2 <- randomUser d2 def - u2Handle <- API.randomHandle - bindResponse (BrigP.putHandle u2 u2Handle) $ assertSuccess - BrigI.refreshIndex d2 - - bindResponse (BrigP.searchContacts u1 u2Handle d2) $ \resp -> do - resp.status `shouldMatchInt` 200 - docs <- resp.json %. "documents" >>= asList - case docs of - [] -> assertFailure "Expected a non empty result, but got an empty one" - doc : _ -> objQid doc `shouldMatch` objQid u2 - testCrudFederationRemoteTeams :: HasCallStack => App () testCrudFederationRemoteTeams = do (_, tid, _) <- createTeam OwnDomain 1 (_, tid2, _) <- createTeam OwnDomain 1 - let rd = "some-remote-domain.wire.com" + rd <- (\n -> n <> ".wire.com") <$> randomName + bindResponse (BrigI.addFederationRemoteTeam' OwnDomain rd tid) $ \resp -> do + resp.status `shouldMatchInt` 533 + void $ BrigI.createFedConn OwnDomain $ BrigI.FedConn rd "full_search" Nothing + bindResponse (BrigI.addFederationRemoteTeam' OwnDomain rd tid) $ \resp -> do + resp.status `shouldMatchInt` 533 + void $ BrigI.updateFedConn OwnDomain rd $ BrigI.FedConn rd "full_search" (Just []) bindResponse (BrigI.getFederationRemoteTeams OwnDomain rd) $ \resp -> do resp.status `shouldMatchInt` 200 checkAbsence resp [tid, tid2] diff --git a/integration/test/Test/Conversation.hs b/integration/test/Test/Conversation.hs index 73464a7973..87b588614e 100644 --- a/integration/test/Test/Conversation.hs +++ b/integration/test/Test/Conversation.hs @@ -78,7 +78,7 @@ testDynamicBackendsFullyConnectedWhenAllowDynamic = do -- Allowing 'full_search' or any type of search is how we enable federation -- between backends when the federation strategy is 'allowDynamic'. sequence_ - [ createFedConn x (FedConn y "full_search" "allow_all") + [ createFedConn x (FedConn y "full_search" Nothing) | x <- [domainA, domainB, domainC], y <- [domainA, domainB, domainC], x /= y @@ -100,10 +100,10 @@ testDynamicBackendsNotFullyConnected :: HasCallStack => App () testDynamicBackendsNotFullyConnected = do withFederatingBackendsAllowDynamic $ \(domainA, domainB, domainC) -> do -- A is connected to B and C, but B and C are not connected to each other - void $ createFedConn domainA $ FedConn domainB "full_search" "allow_all" - void $ createFedConn domainB $ FedConn domainA "full_search" "allow_all" - void $ createFedConn domainA $ FedConn domainC "full_search" "allow_all" - void $ createFedConn domainC $ FedConn domainA "full_search" "allow_all" + void $ createFedConn domainA $ FedConn domainB "full_search" Nothing + void $ createFedConn domainB $ FedConn domainA "full_search" Nothing + void $ createFedConn domainA $ FedConn domainC "full_search" Nothing + void $ createFedConn domainC $ FedConn domainA "full_search" Nothing uidA <- randomUser domainA def {team = True} retryT $ bindResponse @@ -149,10 +149,10 @@ testCreateConversationNonFullyConnected :: HasCallStack => App () testCreateConversationNonFullyConnected = do withFederatingBackendsAllowDynamic $ \(domainA, domainB, domainC) -> do -- A is connected to B and C, but B and C are not connected to each other - void $ createFedConn domainA $ FedConn domainB "full_search" "allow_all" - void $ createFedConn domainB $ FedConn domainA "full_search" "allow_all" - void $ createFedConn domainA $ FedConn domainC "full_search" "allow_all" - void $ createFedConn domainC $ FedConn domainA "full_search" "allow_all" + void $ createFedConn domainA $ FedConn domainB "full_search" Nothing + void $ createFedConn domainB $ FedConn domainA "full_search" Nothing + void $ createFedConn domainA $ FedConn domainC "full_search" Nothing + void $ createFedConn domainC $ FedConn domainA "full_search" Nothing liftIO $ threadDelay (2 * 1000 * 1000) u1 <- randomUser domainA def @@ -184,10 +184,10 @@ testAddMembersFullyConnectedProteus = do testAddMembersNonFullyConnectedProteus :: HasCallStack => App () testAddMembersNonFullyConnectedProteus = do withFederatingBackendsAllowDynamic $ \(domainA, domainB, domainC) -> do - void $ createFedConn domainA (FedConn domainB "full_search" "allow_all") - void $ createFedConn domainB (FedConn domainA "full_search" "allow_all") - void $ createFedConn domainA (FedConn domainC "full_search" "allow_all") - void $ createFedConn domainC (FedConn domainA "full_search" "allow_all") + void $ createFedConn domainA (FedConn domainB "full_search" Nothing) + void $ createFedConn domainB (FedConn domainA "full_search" Nothing) + void $ createFedConn domainA (FedConn domainC "full_search" Nothing) + void $ createFedConn domainC (FedConn domainA "full_search" Nothing) liftIO $ threadDelay (2 * 1000 * 1000) -- wait for federation status to be updated -- add users @@ -386,7 +386,7 @@ testAddingUserNonFullyConnectedFederation = do -- Ensure that dynamic backend only federates with own domain, but not other -- domain. - void $ createFedConn dynBackend (FedConn own "full_search" "allow_all") + void $ createFedConn dynBackend (FedConn own "full_search" Nothing) alice <- randomUser own def bob <- randomUser other def diff --git a/integration/test/Test/Search.hs b/integration/test/Test/Search.hs new file mode 100644 index 0000000000..99ffc44061 --- /dev/null +++ b/integration/test/Test/Search.hs @@ -0,0 +1,186 @@ +module Test.Search where + +import API.Brig qualified as BrigP +import API.BrigInternal qualified as BrigI +import API.Common qualified as API +import API.GalleyInternal qualified as GalleyI +import GHC.Stack +import SetupHelpers +import Testlib.Assertions +import Testlib.Prelude + +-------------------------------------------------------------------------------- +-- LOCAL SEARCH + +testSearchContactForExternalUsers :: HasCallStack => App () +testSearchContactForExternalUsers = do + owner <- randomUser OwnDomain def {BrigI.team = True} + partner <- randomUser OwnDomain def {BrigI.team = True} + + bindResponse (GalleyI.putTeamMember partner (partner %. "team") (API.teamRole "partner")) $ \resp -> + resp.status `shouldMatchInt` 200 + + bindResponse (BrigP.searchContacts partner (owner %. "name") OwnDomain) $ \resp -> + resp.status `shouldMatchInt` 403 + +-------------------------------------------------------------------------------- +-- FEDERATION SEARCH + +-- | Enumeration of the possible restrictions that can be applied to a federated user search +data Restriction = AllowAll | TeamAllowed | TeamNotAllowed + deriving (Eq, Ord, Show) + +data FedUserSearchTestCase = FedUserSearchTestCase + { searchPolicy :: String, + -- restriction settings of the calling backend + restrictionD1D2 :: Restriction, + -- restriction settings of the remote backend + restrictionD2D1 :: Restriction, + exactHandleSearchExpectFound :: Bool, + fullSearchExpectFound :: Bool + } + deriving (Eq, Ord, Show) + +testFederatedUserSearch :: HasCallStack => App () +testFederatedUserSearch = do + let testCases = + [ -- no search + FedUserSearchTestCase "no_search" AllowAll AllowAll False False, + FedUserSearchTestCase "no_search" TeamAllowed TeamAllowed False False, + -- exact handle search allow all/team allowed + FedUserSearchTestCase "exact_handle_search" AllowAll AllowAll True False, + FedUserSearchTestCase "exact_handle_search" TeamAllowed TeamAllowed True False, + FedUserSearchTestCase "exact_handle_search" AllowAll TeamAllowed True False, + FedUserSearchTestCase "exact_handle_search" TeamAllowed AllowAll True False, + -- exact handle search team not allowed + FedUserSearchTestCase "exact_handle_search" TeamNotAllowed AllowAll False False, + FedUserSearchTestCase "exact_handle_search" AllowAll TeamNotAllowed False False, + -- full search allow all/team allowed + FedUserSearchTestCase "full_search" AllowAll AllowAll True True, + FedUserSearchTestCase "full_search" TeamAllowed TeamAllowed True True, + FedUserSearchTestCase "full_search" TeamAllowed AllowAll True True, + FedUserSearchTestCase "full_search" AllowAll TeamAllowed True True, + -- full search team not allowed + FedUserSearchTestCase "full_search" TeamNotAllowed AllowAll False False, + FedUserSearchTestCase "full_search" AllowAll TeamNotAllowed False False + ] + startDynamicBackends [def, def] $ \[d1, d2] -> do + void $ BrigI.createFedConn d2 (BrigI.FedConn d1 "full_search" Nothing) + void $ BrigI.createFedConn d1 (BrigI.FedConn d2 "full_search" Nothing) + forM_ testCases (federatedUserSearch d1 d2) + +federatedUserSearch :: HasCallStack => String -> String -> FedUserSearchTestCase -> App () +federatedUserSearch d1 d2 test = do + void $ BrigI.updateFedConn d2 d1 (BrigI.FedConn d1 test.searchPolicy (restriction test.restrictionD2D1)) + void $ BrigI.updateFedConn d1 d2 (BrigI.FedConn d2 test.searchPolicy (restriction test.restrictionD1D2)) + + u1 <- randomUser d1 def {BrigI.team = True} + teamU1 <- u1 %. "team" + u2 <- randomUser d2 def {BrigI.team = True} + uidD2 <- objId u2 + team2 <- u2 %. "team" + GalleyI.setTeamFeatureStatus d2 team2 "searchVisibilityInbound" "enabled" + + addTeamRestriction d1 d2 team2 test.restrictionD1D2 + addTeamRestriction d2 d1 teamU1 test.restrictionD2D1 + + u2Handle <- API.randomHandle + bindResponse (BrigP.putHandle u2 u2Handle) $ assertSuccess + BrigI.refreshIndex d2 + + bindResponse (BrigP.searchContacts u1 u2Handle d2) $ \resp -> do + resp.status `shouldMatchInt` 200 + docs <- resp.json %. "documents" >>= asList + case docs of + [] -> + when (test.exactHandleSearchExpectFound) $ assertFailure $ "Expected a non empty result, but got an empty one, for test case " <> show test + doc : _ -> + if test.exactHandleSearchExpectFound + then doc %. "id" `shouldMatch` uidD2 + else assertFailure $ "Expected an empty result, but got " <> show doc <> " for test case " <> show test + + bindResponse (BrigP.searchContacts u1 (u2 %. "name") d2) $ \resp -> do + resp.status `shouldMatchInt` 200 + docs <- resp.json %. "documents" >>= asList + case docs of + [] -> when (test.fullSearchExpectFound) $ assertFailure $ "Expected a non empty result, but got an empty one, for test case " <> show test + doc : _ -> + if test.fullSearchExpectFound + then doc %. "id" `shouldMatch` uidD2 + else assertFailure $ "Expected an empty result, but got " <> show doc <> " for test case " <> show test + where + restriction :: Restriction -> Maybe [String] + restriction = \case + AllowAll -> Nothing + TeamAllowed -> Just [] + TeamNotAllowed -> Just [] + + addTeamRestriction :: (MakesValue ownDomain, MakesValue remoteDomain, MakesValue remoteTeam) => ownDomain -> remoteDomain -> remoteTeam -> Restriction -> App () + addTeamRestriction ownDomain remoteDomain remoteTeam = \case + AllowAll -> + pure () + TeamNotAllowed -> + -- if the team is not allowed, the restriction was set to by team earlier and we do not need to do anything + pure () + TeamAllowed -> do + BrigI.addFederationRemoteTeam ownDomain remoteDomain remoteTeam + +testFederatedUserSearchNonTeamSearcher :: HasCallStack => App () +testFederatedUserSearchNonTeamSearcher = do + startDynamicBackends [def, def] $ \[d1, d2] -> do + void $ BrigI.createFedConn d2 (BrigI.FedConn d1 "full_search" (Just [])) + void $ BrigI.createFedConn d1 (BrigI.FedConn d2 "full_search" Nothing) + + u1 <- randomUser d1 def + u2 <- randomUser d2 def {BrigI.team = True} + team2 <- u2 %. "team" + GalleyI.setTeamFeatureStatus d2 team2 "searchVisibilityInbound" "enabled" + + u2Handle <- API.randomHandle + bindResponse (BrigP.putHandle u2 u2Handle) $ assertSuccess + BrigI.refreshIndex d2 + + bindResponse (BrigP.searchContacts u1 u2Handle d2) $ \resp -> do + resp.status `shouldMatchInt` 200 + docs <- resp.json %. "documents" >>= asList + case docs of + [] -> pure () + doc : _ -> + assertFailure $ "Expected an empty result, but got " <> show doc + + bindResponse (BrigP.searchContacts u1 (u2 %. "name") d2) $ \resp -> do + resp.status `shouldMatchInt` 200 + docs <- resp.json %. "documents" >>= asList + case docs of + [] -> pure () + doc : _ -> + assertFailure $ "Expected an empty result, but got " <> show doc <> " for test case " + +testFederatedUserSearchForNonTeamUser :: HasCallStack => App () +testFederatedUserSearchForNonTeamUser = do + startDynamicBackends [def, def] $ \[d1, d2] -> do + void $ BrigI.createFedConn d2 (BrigI.FedConn d1 "full_search" Nothing) + void $ BrigI.createFedConn d1 (BrigI.FedConn d2 "full_search" (Just [])) + + u1 <- randomUser d1 def {BrigI.team = True} + u2 <- randomUser d2 def + + u2Handle <- API.randomHandle + bindResponse (BrigP.putHandle u2 u2Handle) $ assertSuccess + BrigI.refreshIndex d2 + + bindResponse (BrigP.searchContacts u1 u2Handle d2) $ \resp -> do + resp.status `shouldMatchInt` 200 + docs <- resp.json %. "documents" >>= asList + case docs of + [] -> pure () + doc : _ -> + assertFailure $ "Expected an empty result, but got " <> show doc + + bindResponse (BrigP.searchContacts u1 (u2 %. "name") d2) $ \resp -> do + resp.status `shouldMatchInt` 200 + docs <- resp.json %. "documents" >>= asList + case docs of + [] -> pure () + doc : _ -> + assertFailure $ "Expected an empty result, but got " <> show doc <> " for test case " diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs b/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs index ed3586aa37..94ecad6dba 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs @@ -44,7 +44,13 @@ import Wire.API.UserMap (UserMap) import Wire.API.Util.Aeson (CustomEncoded (..)) import Wire.Arbitrary (GenericUniform (..)) -newtype SearchRequest = SearchRequest {term :: Text} +data SearchRequest = SearchRequest + { term :: Text, + -- | The searcher's team ID, used to matched against the remote backend's team federation policy. + from :: Maybe TeamId, + -- | The remote teams that the calling backend is allowed to federate with. + onlyInTeams :: Maybe [TeamId] + } deriving (Show, Eq, Generic, Typeable) deriving (Arbitrary) via (GenericUniform SearchRequest) diff --git a/libs/wire-api-federation/test/Test/Wire/API/Federation/API/BrigSpec.hs b/libs/wire-api-federation/test/Test/Wire/API/Federation/API/BrigSpec.hs index 90031646ea..f43bac1a89 100644 --- a/libs/wire-api-federation/test/Test/Wire/API/Federation/API/BrigSpec.hs +++ b/libs/wire-api-federation/test/Test/Wire/API/Federation/API/BrigSpec.hs @@ -33,7 +33,7 @@ spec = describe "Wire.API.Federation.API.Brig" $ do describe "RoundTripTests" $ do jsonRoundTrip @SearchRequest describe "JSON Golden Tests" $ do - jsonGoldenTest "SearchRequest" [aesonQQ|{"term": "searchedThing"}|] (SearchRequest "searchedThing") + jsonGoldenTest "SearchRequest" [aesonQQ|{"term": "searchedThing"}|] (SearchRequest "searchedThing" Nothing Nothing) -- | FUTUREWORK: Extract this into a library so it is not repeated everywhere. jsonRoundTrip :: forall a. (Arbitrary a, Typeable a, ToJSON a, FromJSON a, Eq a, Show a) => Spec diff --git a/libs/wire-api/src/Wire/API/Routes/FederationDomainConfig.hs b/libs/wire-api/src/Wire/API/Routes/FederationDomainConfig.hs index e6ed4ecc4c..74257a99e6 100644 --- a/libs/wire-api/src/Wire/API/Routes/FederationDomainConfig.hs +++ b/libs/wire-api/src/Wire/API/Routes/FederationDomainConfig.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE TemplateHaskell #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -25,7 +27,8 @@ module Wire.API.Routes.FederationDomainConfig ) where -import Control.Lens ((?~)) +import Control.Lens (makePrisms, (?~)) +import Control.Lens.Tuple (_1) import Data.Aeson (FromJSON, ToJSON) import Data.Domain (Domain) import Data.Id @@ -36,25 +39,54 @@ import Imports import Wire.API.User.Search (FederatedUserSearchPolicy) import Wire.Arbitrary (Arbitrary, GenericUniform (..)) -data FederationRestriction = FederationRestrictionAllowAll | FederationRestrictionByTeam +data FederationRestriction = FederationRestrictionAllowAll | FederationRestrictionByTeam [TeamId] deriving (Eq, Show, Generic, Ord) - deriving (ToJSON, FromJSON, S.ToSchema) via Schema FederationRestriction deriving (Arbitrary) via (GenericUniform FederationRestriction) +makePrisms ''FederationRestriction + +data FederationRestrictionTag = FederationRestrictionAllowAllTag | FederationRestrictionByTeamTag + deriving (Eq, Enum, Bounded) + +makePrisms ''FederationRestrictionTag + +deriving via Schema FederationRestriction instance (S.ToSchema FederationRestriction) + +deriving via Schema FederationRestriction instance (FromJSON FederationRestriction) + +deriving via Schema FederationRestriction instance (ToJSON FederationRestriction) + +tagSchema :: ValueSchema NamedSwaggerDoc FederationRestrictionTag +tagSchema = + enum @Text "FederationRestrictionTag" $ + mconcat [element "allow_all" FederationRestrictionAllowAllTag, element "restrict_by_team" FederationRestrictionByTeamTag] + instance ToSchema FederationRestriction where schema = - enum @Text "FederationRestriction" $ - mconcat - [ element "allow_all" FederationRestrictionAllowAll, - element "restrict_by_team" FederationRestrictionByTeam - ] + object "FederationRestriction" $ + fromTagged + <$> toTagged + .= bind + (fst .= field "tag" tagSchema) + (snd .= fieldOver _1 "value" untaggedSchema) + where + toTagged :: FederationRestriction -> (FederationRestrictionTag, FederationRestriction) + toTagged d@(FederationRestrictionAllowAll) = (FederationRestrictionAllowAllTag, d) + toTagged d@(FederationRestrictionByTeam _) = (FederationRestrictionByTeamTag, d) + + fromTagged :: (FederationRestrictionTag, FederationRestriction) -> FederationRestriction + fromTagged = snd + + untaggedSchema = dispatch $ \case + FederationRestrictionAllowAllTag -> tag _FederationRestrictionAllowAll null_ + FederationRestrictionByTeamTag -> tag _FederationRestrictionByTeam (array schema) -- | Everything we need to know about a remote instance in order to federate with it. Comes -- in `AllowedDomains` if `AllowStrategy` is `AllowDynamic`. If `AllowAll`, we still use this -- information for search policy. data FederationDomainConfig = FederationDomainConfig { domain :: Domain, - cfgSearchPolicy :: FederatedUserSearchPolicy, + searchPolicy :: FederatedUserSearchPolicy, restriction :: FederationRestriction } deriving (Eq, Ord, Show, Generic) @@ -66,7 +98,7 @@ instance ToSchema FederationDomainConfig where object "FederationDomainConfig" $ FederationDomainConfig <$> domain .= field "domain" schema - <*> cfgSearchPolicy .= field "search_policy" schema + <*> searchPolicy .= field "search_policy" schema <*> restriction .= field "restriction" schema data FederationDomainConfigs = FederationDomainConfigs diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index 6a8e528ad2..e67567078a 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -30,6 +30,8 @@ import Test.Wire.API.Golden.Manual.ConversationsResponse import Test.Wire.API.Golden.Manual.CreateGroupConversation import Test.Wire.API.Golden.Manual.CreateScimToken import Test.Wire.API.Golden.Manual.FeatureConfigEvent +import Test.Wire.API.Golden.Manual.FederationDomainConfig +import Test.Wire.API.Golden.Manual.FederationRestriction import Test.Wire.API.Golden.Manual.FederationStatus import Test.Wire.API.Golden.Manual.GetPaginatedConversationIds import Test.Wire.API.Golden.Manual.GroupId @@ -172,5 +174,17 @@ tests = testObjects [ (testObject_RemoteDomains_1, "testObject_RemoteDomains_1.json"), (testObject_RemoteDomains_2, "testObject_RemoteDomains_2.json") + ], + testGroup "FederationDomainConfig" $ + testObjects + [ (testObject_FederationDomainConfig_1, "testObject_FederationDomainConfig_1.json"), + (testObject_FederationDomainConfig_2, "testObject_FederationDomainConfig_2.json"), + (testObject_FederationDomainConfig_3, "testObject_FederationDomainConfig_3.json") + ], + testGroup "FederationRestriction" $ + testObjects + [ (testObject_FederationRestriction_1, "testObject_FederationRestriction_1.json"), + (testObject_FederationRestriction_2, "testObject_FederationRestriction_2.json"), + (testObject_FederationRestriction_3, "testObject_FederationRestriction_3.json") ] ] diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/FederationDomainConfig.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/FederationDomainConfig.hs new file mode 100644 index 0000000000..6f06862589 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/FederationDomainConfig.hs @@ -0,0 +1,40 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 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 Test.Wire.API.Golden.Manual.FederationDomainConfig where + +import Data.Domain (Domain (Domain)) +import Data.Id +import Data.UUID qualified as UUID +import Imports +import Wire.API.Routes.FederationDomainConfig +import Wire.API.User.Search + +testObject_FederationDomainConfig_1 :: FederationDomainConfig +testObject_FederationDomainConfig_1 = + FederationDomainConfig (Domain "foo.example.com") FullSearch FederationRestrictionAllowAll + +testObject_FederationDomainConfig_2 :: FederationDomainConfig +testObject_FederationDomainConfig_2 = FederationDomainConfig (Domain "foo.example.com") FullSearch (FederationRestrictionByTeam []) + +testObject_FederationDomainConfig_3 :: FederationDomainConfig +testObject_FederationDomainConfig_3 = + FederationDomainConfig (Domain "foo.example.com") FullSearch $ + FederationRestrictionByTeam + [ Id (fromJust (UUID.fromString "0000304a-0000-0d5e-0000-3fac00003993")), + Id (fromJust (UUID.fromString "00003c90-0000-2207-0000-5249000018b1")) + ] diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/FederationRestriction.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/FederationRestriction.hs new file mode 100644 index 0000000000..315a7c44c2 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/FederationRestriction.hs @@ -0,0 +1,36 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 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 Test.Wire.API.Golden.Manual.FederationRestriction where + +import Data.Id +import Data.UUID qualified as UUID +import Imports +import Wire.API.Routes.FederationDomainConfig + +testObject_FederationRestriction_1 :: FederationRestriction +testObject_FederationRestriction_1 = FederationRestrictionAllowAll + +testObject_FederationRestriction_2 :: FederationRestriction +testObject_FederationRestriction_2 = FederationRestrictionByTeam [] + +testObject_FederationRestriction_3 :: FederationRestriction +testObject_FederationRestriction_3 = + FederationRestrictionByTeam + [ Id (fromJust (UUID.fromString "0000304a-0000-0d5e-0000-3fac00003993")), + Id (fromJust (UUID.fromString "00003c90-0000-2207-0000-5249000018b1")) + ] diff --git a/libs/wire-api/test/golden/testObject_FederationDomainConfig_1.json b/libs/wire-api/test/golden/testObject_FederationDomainConfig_1.json new file mode 100644 index 0000000000..d4d1b4a5b6 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_FederationDomainConfig_1.json @@ -0,0 +1,8 @@ +{ + "domain": "foo.example.com", + "restriction": { + "tag": "allow_all", + "value": null + }, + "search_policy": "full_search" +} diff --git a/libs/wire-api/test/golden/testObject_FederationDomainConfig_2.json b/libs/wire-api/test/golden/testObject_FederationDomainConfig_2.json new file mode 100644 index 0000000000..1c32964a75 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_FederationDomainConfig_2.json @@ -0,0 +1,8 @@ +{ + "domain": "foo.example.com", + "restriction": { + "tag": "restrict_by_team", + "value": [] + }, + "search_policy": "full_search" +} diff --git a/libs/wire-api/test/golden/testObject_FederationDomainConfig_3.json b/libs/wire-api/test/golden/testObject_FederationDomainConfig_3.json new file mode 100644 index 0000000000..ace4103d98 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_FederationDomainConfig_3.json @@ -0,0 +1,11 @@ +{ + "domain": "foo.example.com", + "restriction": { + "tag": "restrict_by_team", + "value": [ + "0000304a-0000-0d5e-0000-3fac00003993", + "00003c90-0000-2207-0000-5249000018b1" + ] + }, + "search_policy": "full_search" +} diff --git a/libs/wire-api/test/golden/testObject_FederationRestriction_1.json b/libs/wire-api/test/golden/testObject_FederationRestriction_1.json new file mode 100644 index 0000000000..c23bb04a45 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_FederationRestriction_1.json @@ -0,0 +1,4 @@ +{ + "tag": "allow_all", + "value": null +} diff --git a/libs/wire-api/test/golden/testObject_FederationRestriction_2.json b/libs/wire-api/test/golden/testObject_FederationRestriction_2.json new file mode 100644 index 0000000000..2af921efe7 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_FederationRestriction_2.json @@ -0,0 +1,4 @@ +{ + "tag": "restrict_by_team", + "value": [] +} diff --git a/libs/wire-api/test/golden/testObject_FederationRestriction_3.json b/libs/wire-api/test/golden/testObject_FederationRestriction_3.json new file mode 100644 index 0000000000..da09dfe768 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_FederationRestriction_3.json @@ -0,0 +1,7 @@ +{ + "tag": "restrict_by_team", + "value": [ + "0000304a-0000-0d5e-0000-3fac00003993", + "00003c90-0000-2207-0000-5249000018b1" + ] +} diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 16b9c9b54e..be55353f40 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -571,6 +571,8 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Manual.CreateGroupConversation Test.Wire.API.Golden.Manual.CreateScimToken Test.Wire.API.Golden.Manual.FeatureConfigEvent + Test.Wire.API.Golden.Manual.FederationDomainConfig + Test.Wire.API.Golden.Manual.FederationRestriction Test.Wire.API.Golden.Manual.FederationStatus Test.Wire.API.Golden.Manual.GetPaginatedConversationIds Test.Wire.API.Golden.Manual.GroupId diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index d6daec5b14..93622af252 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -110,7 +110,6 @@ library Brig.Data.Activation Brig.Data.Client Brig.Data.Connection - Brig.Data.Federation Brig.Data.Instances Brig.Data.LoginCode Brig.Data.MLS.KeyPackage @@ -126,6 +125,8 @@ library Brig.Effects.CodeStore Brig.Effects.CodeStore.Cassandra Brig.Effects.Delay + Brig.Effects.FederationConfigStore + Brig.Effects.FederationConfigStore.Cassandra Brig.Effects.GalleyProvider Brig.Effects.GalleyProvider.RPC Brig.Effects.JwtTools @@ -318,7 +319,6 @@ library , polysemy-plugin , polysemy-wire-zoo , proto-lens >=0.1 - , random , random-shuffle >=0.0.3 , raw-strings-qq , resource-pool >=0.2 diff --git a/services/brig/default.nix b/services/brig/default.nix index 6887c802f3..14f1634b1d 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -242,7 +242,6 @@ mkDerivation { polysemy-plugin polysemy-wire-zoo proto-lens - random random-shuffle raw-strings-qq resource-pool diff --git a/services/brig/src/Brig/API/Federation.hs b/services/brig/src/Brig/API/Federation.hs index 90ddd22a28..f956eef123 100644 --- a/services/brig/src/Brig/API/Federation.hs +++ b/services/brig/src/Brig/API/Federation.hs @@ -23,7 +23,6 @@ import Brig.API.Client qualified as API import Brig.API.Connection.Remote (performRemoteAction) import Brig.API.Error import Brig.API.Handler (Handler) -import Brig.API.Internal hiding (getMLSClients) import Brig.API.Internal qualified as Internal import Brig.API.MLS.CipherSuite import Brig.API.MLS.KeyPackages @@ -32,6 +31,8 @@ import Brig.API.User qualified as API import Brig.App import Brig.Data.Connection qualified as Data import Brig.Data.User qualified as Data +import Brig.Effects.FederationConfigStore (FederationConfigStore) +import Brig.Effects.FederationConfigStore qualified as E import Brig.Effects.GalleyProvider (GalleyProvider) import Brig.IO.Intra (notify) import Brig.Options @@ -43,7 +44,7 @@ import Control.Lens ((^.)) import Control.Monad.Trans.Except import Data.Domain import Data.Handle (Handle (..), parseHandle) -import Data.Id (ClientId, UserId) +import Data.Id (ClientId, TeamId, UserId) import Data.List.NonEmpty (nonEmpty) import Data.List1 import Data.Qualified @@ -57,7 +58,7 @@ import Servant (ServerT) import Servant.API import UnliftIO.Async (pooledForConcurrentlyN_) import Wire.API.Connection -import Wire.API.Federation.API.Brig +import Wire.API.Federation.API.Brig hiding (searchPolicy) import Wire.API.Federation.API.Common import Wire.API.Federation.Version import Wire.API.MLS.KeyPackage @@ -68,7 +69,7 @@ import Wire.API.Team.LegalHold (LegalholdProtectee (LegalholdPlusFederationNotIm import Wire.API.User (UserProfile) import Wire.API.User.Client import Wire.API.User.Client.Prekey -import Wire.API.User.Search +import Wire.API.User.Search hiding (searchPolicy) import Wire.API.UserMap (UserMap) import Wire.Sem.Concurrency @@ -76,7 +77,8 @@ type FederationAPI = "federation" :> BrigApi federationSitemap :: ( Member GalleyProvider r, - Member (Concurrency 'Unsafe) r + Member (Concurrency 'Unsafe) r, + Member FederationConfigStore r ) => ServerT FederationAPI (Handler r) federationSitemap = @@ -96,13 +98,13 @@ federationSitemap = -- Allow remote domains to send their known remote federation instances, and respond -- with the subset of those we aren't connected to. -getFederationStatus :: Domain -> DomainSet -> Handler r NonConnectedBackends +getFederationStatus :: (Member FederationConfigStore r) => Domain -> DomainSet -> Handler r NonConnectedBackends getFederationStatus _ request = do cfg <- ask case setFederationStrategy (cfg ^. settings) of Just AllowAll -> pure $ NonConnectedBackends mempty _ -> do - fedDomains <- fromList . fmap (.domain) . (.remotes) <$> getFederationRemotes + fedDomains <- fromList . fmap (.domain) . (.remotes) <$> lift (liftSem $ E.getFederationConfigs) pure $ NonConnectedBackends (request.domains \\ fedDomains) sendConnectionAction :: Domain -> NewConnectionRequest -> Handler r NewConnectionResponse @@ -118,7 +120,9 @@ sendConnectionAction originDomain NewConnectionRequest {..} = do else pure NewConnectionResponseUserNotActivated getUserByHandle :: - Member GalleyProvider r => + ( Member GalleyProvider r, + Member FederationConfigStore r + ) => Domain -> Handle -> ExceptT Error (AppT r) (Maybe UserProfile) @@ -174,17 +178,20 @@ fedClaimKeyPackages domain ckpr = claimLocalKeyPackages (tUntagged rusr) Nothing suite ltarget False -> pure Nothing --- | Searching for federated users on a remote backend should --- only search by exact handle search, not in elasticsearch. --- (This decision may change in the future) +-- | Searching for federated users on a remote backend searchUsers :: forall r. - Member GalleyProvider r => + ( Member GalleyProvider r, + Member FederationConfigStore r + ) => Domain -> SearchRequest -> ExceptT Error (AppT r) SearchResponse -searchUsers domain (SearchRequest searchTerm) = do - searchPolicy <- lookupSearchPolicy domain +searchUsers domain (SearchRequest _ mTeam (Just [])) = do + searchPolicy <- lookupSearchPolicyWithTeam domain mTeam + pure $ SearchResponse [] searchPolicy +searchUsers domain (SearchRequest searchTerm mTeam mOnlyInTeams) = do + searchPolicy <- lookupSearchPolicyWithTeam domain mTeam let searches = case searchPolicy of NoSearch -> [] @@ -204,7 +211,7 @@ searchUsers domain (SearchRequest searchTerm) = do fullSearch :: Int -> ExceptT Error (AppT r) [Contact] fullSearch n - | n > 0 = lift $ searchResults <$> Q.searchIndex Q.FederatedSearch searchTerm n + | n > 0 = lift $ searchResults <$> Q.searchIndex (Q.FederatedSearch mOnlyInTeams) searchTerm n | otherwise = pure [] exactHandleSearch :: Int -> ExceptT Error (AppT r) [Contact] @@ -214,9 +221,18 @@ searchUsers domain (SearchRequest searchTerm) = do maybeOwnerId <- maybe (pure Nothing) (wrapHttpClientE . API.lookupHandle) maybeHandle case maybeOwnerId of Nothing -> pure [] - Just foundUser -> lift $ contactFromProfile <$$> API.lookupLocalProfiles Nothing [foundUser] + Just foundUser -> do + mFoundUserTeamId <- lift $ wrapClient $ Data.lookupUserTeam foundUser + if isTeamAllowed mOnlyInTeams mFoundUserTeamId + then lift $ contactFromProfile <$$> API.lookupLocalProfiles Nothing [foundUser] + else pure [] | otherwise = pure [] + isTeamAllowed :: Maybe [TeamId] -> Maybe TeamId -> Bool + isTeamAllowed Nothing _ = True + isTeamAllowed (Just _) Nothing = False + isTeamAllowed (Just teams) (Just tid) = tid `elem` teams + getUserClients :: Domain -> GetUserClients -> (Handler r) (UserMap (Set PubClient)) getUserClients _ (GetUserClients uids) = API.lookupLocalPubClientsBulk uids !>> clientError @@ -240,8 +256,20 @@ onUserDeleted origDomain udcn = lift $ do pure EmptyResponse -- | If domain is not configured fall back to `NoSearch` -lookupSearchPolicy :: Domain -> (Handler r) FederatedUserSearchPolicy +lookupSearchPolicy :: (Member FederationConfigStore r) => Domain -> (Handler r) FederatedUserSearchPolicy lookupSearchPolicy domain = do - domainConfigs <- getFederationRemotes - let mConfig = find ((== domain) . FD.domain) (domainConfigs.remotes) - pure $ maybe NoSearch FD.cfgSearchPolicy mConfig + mConfig <- lift $ liftSem $ E.getFederationConfig domain + pure $ maybe NoSearch searchPolicy mConfig + +-- | If domain is not configured fall back to `NoSearch` +-- if a team is provided, check if the team is allowed to search +-- if no team is provided, and restriction is set by team, fall back to `NoSearch` +lookupSearchPolicyWithTeam :: (Member FederationConfigStore r) => Domain -> Maybe TeamId -> (Handler r) FederatedUserSearchPolicy +lookupSearchPolicyWithTeam domain mSearcherTeamId = + lift $ + liftSem $ + E.getFederationConfig domain <&> \case + Nothing -> NoSearch + Just (FederationDomainConfig _ sp FederationRestrictionAllowAll) -> sp + Just (FederationDomainConfig _ sp (FederationRestrictionByTeam teams)) -> + maybe NoSearch (\tid -> if tid `elem` teams then sp else NoSearch) $ mSearcherTeamId diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 61368a7012..8f84ba4b73 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -19,7 +19,6 @@ module Brig.API.Internal servantSitemap, BrigIRoutes.API, getMLSClients, - getFederationRemotes, ) where @@ -38,12 +37,13 @@ import Brig.Code qualified as Code import Brig.Data.Activation import Brig.Data.Client qualified as Data import Brig.Data.Connection qualified as Data -import Brig.Data.Federation qualified as Data import Brig.Data.MLS.KeyPackage qualified as Data import Brig.Data.User qualified as Data import Brig.Effects.BlacklistPhonePrefixStore (BlacklistPhonePrefixStore) import Brig.Effects.BlacklistStore (BlacklistStore) import Brig.Effects.CodeStore (CodeStore) +import Brig.Effects.FederationConfigStore (AddFederationRemoteResult (..), AddFederationRemoteTeamResult (..), FederationConfigStore, UpdateFederationResult (..)) +import Brig.Effects.FederationConfigStore qualified as E import Brig.Effects.GalleyProvider (GalleyProvider) import Brig.Effects.PasswordResetStore (PasswordResetStore) import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) @@ -62,7 +62,7 @@ import Brig.User.API.Search qualified as Search import Brig.User.EJPD qualified import Brig.User.Search.Index qualified as Index import Control.Error hiding (bool) -import Control.Lens (view, (^.)) +import Control.Lens (view) import Data.CommaSeparatedList import Data.Domain (Domain) import Data.Handle @@ -78,7 +78,6 @@ import Polysemy import Servant hiding (Handler, JSON, addHeader, respond) import Servant.OpenApi.Internal.Orphans () import System.Logger.Class qualified as Log -import System.Random (randomRIO) import UnliftIO.Async import Wire.API.Connection import Wire.API.Error @@ -106,7 +105,8 @@ servantSitemap :: Member BlacklistPhonePrefixStore r, Member PasswordResetStore r, Member GalleyProvider r, - Member (UserPendingActivationStore p) r + Member (UserPendingActivationStore p) r, + Member FederationConfigStore r ) => ServerT BrigIRoutes.API (Handler r) servantSitemap = @@ -214,7 +214,7 @@ authAPI = :<|> Named @"login-code" getLoginCode :<|> Named @"reauthenticate" reauthenticate -federationRemotesAPI :: ServerT BrigIRoutes.FederationRemotesAPI (Handler r) +federationRemotesAPI :: (Member FederationConfigStore r) => ServerT BrigIRoutes.FederationRemotesAPI (Handler r) federationRemotesAPI = Named @"add-federation-remotes" addFederationRemote :<|> Named @"get-federation-remotes" getFederationRemotes @@ -223,121 +223,65 @@ federationRemotesAPI = :<|> Named @"get-federation-remote-teams" getFederationRemoteTeams :<|> Named @"delete-federation-remote-team" deleteFederationRemoteTeam -deleteFederationRemoteTeam :: Domain -> TeamId -> (Handler r) () +deleteFederationRemoteTeam :: (Member FederationConfigStore r) => Domain -> TeamId -> (Handler r) () deleteFederationRemoteTeam domain teamId = - lift . wrapClient $ Data.deleteFederationRemoteTeam domain teamId + lift $ liftSem $ E.removeFederationRemoteTeam domain teamId -getFederationRemoteTeams :: Domain -> (Handler r) [FederationRemoteTeam] +getFederationRemoteTeams :: (Member FederationConfigStore r) => Domain -> (Handler r) [FederationRemoteTeam] getFederationRemoteTeams domain = - lift . wrapClient $ Data.getFederationRemoteTeams domain + lift $ liftSem $ E.getFederationRemoteTeams domain -addFederationRemoteTeam :: Domain -> FederationRemoteTeam -> (Handler r) () +addFederationRemoteTeam :: (Member FederationConfigStore r) => Domain -> FederationRemoteTeam -> (Handler r) () addFederationRemoteTeam domain rt = - lift . wrapClient $ Data.addFederationRemoteTeam domain rt + lift (liftSem $ E.addFederationRemoteTeam domain rt.teamId) >>= \case + AddFederationRemoteTeamSuccess -> pure () + AddFederationRemoteTeamDomainNotFound -> + throwError . fedError . FederationUnexpectedError $ + "Federation domain does not exist. Please add it first." + AddFederationRemoteTeamRestrictionAllowAll -> + throwError . fedError . FederationUnexpectedError $ + "Federation is not configured to be restricted by teams. Therefore adding a team to a \ + \remote domain is not allowed." -addFederationRemote :: FederationDomainConfig -> ExceptT Brig.API.Error.Error (AppT r) () +getFederationRemotes :: (Member FederationConfigStore r) => (Handler r) FederationDomainConfigs +getFederationRemotes = lift $ liftSem $ E.getFederationConfigs + +addFederationRemote :: (Member FederationConfigStore r) => FederationDomainConfig -> (Handler r) () addFederationRemote fedDomConf = do - assertNoDivergingDomainInConfigFiles fedDomConf - result <- lift . wrapClient $ Data.addFederationRemote fedDomConf - case result of - Data.AddFederationRemoteSuccess -> pure () - Data.AddFederationRemoteMaxRemotesReached -> + lift (liftSem $ E.addFederationConfig fedDomConf) >>= \case + AddFederationRemoteSuccess -> pure () + AddFederationRemoteMaxRemotesReached -> throwError . fedError . FederationUnexpectedError $ "Maximum number of remote backends reached. If you need to create more connections, \ \please contact wire.com." - --- | Compile config file list into a map indexed by domains. Use this to make sure the config --- file is consistent (ie., no two entries for the same domain). -remotesMapFromCfgFile :: AppT r (Map Domain FederationDomainConfig) -remotesMapFromCfgFile = do - cfg <- fmap (.federationDomainConfig) <$> asks (fromMaybe [] . setFederationDomainConfigs . view settings) - let dict = [(cnf.domain, cnf) | cnf <- cfg] - merge c c' = - if c == c' - then c - else error $ "error in config file: conflicting parameters on domain: " <> show (c, c') - pure $ Map.fromListWith merge dict - --- | Return the config file list. Use this to make sure the config file is consistent (ie., --- no two entries for the same domain). Based on `remotesMapFromCfgFile`. -remotesListFromCfgFile :: AppT r [FederationDomainConfig] -remotesListFromCfgFile = Map.elems <$> remotesMapFromCfgFile - --- | If remote domain is registered in config file, the version that can be added to the --- database must be the same. -assertNoDivergingDomainInConfigFiles :: FederationDomainConfig -> ExceptT Brig.API.Error.Error (AppT r) () -assertNoDivergingDomainInConfigFiles fedComConf = do - cfg <- lift remotesMapFromCfgFile - let diverges = case Map.lookup (domain fedComConf) cfg of - Nothing -> False - Just fedComConf' -> fedComConf' /= fedComConf - when diverges $ do - throwError . fedError . FederationUnexpectedError $ - "keeping track of remote domains in the brig config file is deprecated, but as long as we \ - \do that, adding a domain with different settings than in the config file is nto allowed. want " - <> ( "Just " - <> cs (show fedComConf) - <> "or Nothing, " - ) - <> ( "got " - <> cs (show (Map.lookup (domain fedComConf) cfg)) - ) - -getFederationRemotes :: ExceptT Brig.API.Error.Error (AppT r) FederationDomainConfigs -getFederationRemotes = lift $ do - -- FUTUREWORK: we should solely rely on `db` in the future for remote domains; merging - -- remote domains from `cfg` is just for providing an easier, more robust migration path. - -- See - -- https://docs.wire.com/understand/federation/backend-communication.html#configuring-remote-connections, - -- http://docs.wire.com/developer/developer/federation-design-aspects.html#configuring-remote-connections-dev-perspective - db <- wrapClient Data.getFederationRemotes - (ms :: Maybe FederationStrategy, mf :: [FederationDomainConfig], mu :: Maybe Int) <- do - cfg <- ask - domcfgs <- remotesListFromCfgFile -- (it's not very elegant to prove the env twice here, but this code is transitory.) - pure - ( setFederationStrategy (cfg ^. settings), - domcfgs, - setFederationDomainConfigsUpdateFreq (cfg ^. settings) - ) - - -- update frequency settings of `<1` are interpreted as `1 second`. only warn about this every now and - -- then, that'll be noise enough for the logs given the traffic on this end-point. - unless (maybe True (> 0) mu) $ - randomRIO (0 :: Int, 1000) - >>= \case - 0 -> Log.warn (Log.msg (Log.val "Invalid brig configuration: setFederationDomainConfigsUpdateFreq must be > 0. setting to 1 second.")) - _ -> pure () - - defFederationDomainConfigs - & maybe id (\v cfg -> cfg {strategy = v}) ms - & (\cfg -> cfg {remotes = nub $ db <> mf}) - & maybe id (\v cfg -> cfg {updateInterval = min 1 v}) mu - & pure - -updateFederationRemote :: Domain -> FederationDomainConfig -> ExceptT Brig.API.Error.Error (AppT r) () + AddFederationRemoteDivergingConfig cfg -> + throwError . fedError . FederationUnexpectedError $ + "keeping track of remote domains in the brig config file is deprecated, but as long as we \ + \do that, adding a domain with different settings than in the config file is not allowed. want " + <> ( "Just " + <> cs (show fedDomConf) + <> "or Nothing, " + ) + <> ( "got " + <> cs (show (Map.lookup (domain fedDomConf) cfg)) + ) + +updateFederationRemote :: (Member FederationConfigStore r) => Domain -> FederationDomainConfig -> (Handler r) () updateFederationRemote dom fedcfg = do - assertDomainIsNotUpdated dom fedcfg - assertNoDomainsFromConfigFiles dom - (lift . wrapClient . Data.updateFederationRemote $ fedcfg) >>= \case - True -> pure () - False -> + if (dom /= fedcfg.domain) + then throwError . fedError . FederationUnexpectedError . cs $ - "federation domain does not exist and cannot be updated: " <> show (dom, fedcfg) - -assertDomainIsNotUpdated :: Domain -> FederationDomainConfig -> ExceptT Brig.API.Error.Error (AppT r) () -assertDomainIsNotUpdated dom fedcfg = do - when (dom /= domain fedcfg) $ - throwError . fedError . FederationUnexpectedError . cs $ - "federation domain of a given peer cannot be changed from " <> show (domain fedcfg) <> " to " <> show dom <> "." - --- | FUTUREWORK: should go away in the future; see 'getFederationRemotes'. -assertNoDomainsFromConfigFiles :: Domain -> ExceptT Brig.API.Error.Error (AppT r) () -assertNoDomainsFromConfigFiles dom = do - cfg <- fmap (.federationDomainConfig) <$> asks (fromMaybe [] . setFederationDomainConfigs . view settings) - when (dom `elem` (domain <$> cfg)) $ do - throwError . fedError . FederationUnexpectedError $ - "keeping track of remote domains in the brig config file is deprecated, but as long as we \ - \do that, removing or updating items listed in the config file is not allowed." + "federation domain of a given peer cannot be changed from " <> show (domain fedcfg) <> " to " <> show dom <> "." + else + lift (liftSem (E.updateFederationConfig fedcfg)) >>= \case + UpdateFederationSuccess -> pure () + UpdateFederationRemoteNotFound -> + throwError . fedError . FederationUnexpectedError . cs $ + "federation domain does not exist and cannot be updated: " <> show (dom, fedcfg) + UpdateFederationRemoteDivergingConfig -> + throwError . fedError . FederationUnexpectedError $ + "keeping track of remote domains in the brig config file is deprecated, but as long as we \ + \do that, removing or updating items listed in the config file is not allowed." -- | Responds with 'Nothing' if field is NULL in existing user or user does not exist. getAccountConferenceCallingConfig :: UserId -> (Handler r) (ApiFt.WithStatusNoLock ApiFt.ConferenceCallingConfig) diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 2f20f960bb..5d07821f19 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -48,6 +48,7 @@ import Brig.Data.UserKey qualified as UserKey import Brig.Effects.BlacklistPhonePrefixStore (BlacklistPhonePrefixStore) import Brig.Effects.BlacklistStore (BlacklistStore) import Brig.Effects.CodeStore (CodeStore) +import Brig.Effects.FederationConfigStore (FederationConfigStore) import Brig.Effects.GalleyProvider (GalleyProvider) import Brig.Effects.GalleyProvider qualified as GalleyProvider import Brig.Effects.JwtTools (JwtTools) @@ -268,7 +269,8 @@ servantSitemap :: Member PasswordResetStore r, Member PublicKeyBundle r, Member (UserPendingActivationStore p) r, - Member Jwk r + Member Jwk r, + Member FederationConfigStore r ) => ServerT BrigAPI (Handler r) servantSitemap = diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index 5322de6392..28231b0606 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -7,6 +7,8 @@ import Brig.Effects.BlacklistStore (BlacklistStore) import Brig.Effects.BlacklistStore.Cassandra (interpretBlacklistStoreToCassandra) import Brig.Effects.CodeStore (CodeStore) import Brig.Effects.CodeStore.Cassandra (codeStoreToCassandra, interpretClientToIO) +import Brig.Effects.FederationConfigStore (FederationConfigStore) +import Brig.Effects.FederationConfigStore.Cassandra (interpretFederationDomainConfig, remotesMapFromCfgFile) import Brig.Effects.GalleyProvider (GalleyProvider) import Brig.Effects.GalleyProvider.RPC (interpretGalleyProviderToRPC) import Brig.Effects.JwtTools @@ -19,6 +21,7 @@ import Brig.Effects.ServiceRPC (Service (Galley), ServiceRPC) import Brig.Effects.ServiceRPC.IO (interpretServiceRpcToRpc) import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Effects.UserPendingActivationStore.Cassandra (userPendingActivationStoreToCassandra) +import Brig.Options (ImplicitNoFederationRestriction (federationDomainConfig), federationDomainConfigs, federationStrategy) import Brig.RPC (ParseException) import Cassandra qualified as Cas import Control.Lens ((^.)) @@ -36,7 +39,8 @@ import Wire.Sem.Now.IO (nowToIOAction) import Wire.Sem.Paging.Cassandra (InternalPaging) type BrigCanonicalEffects = - '[ Jwk, + '[ FederationConfigStore, + Jwk, PublicKeyBundle, JwtTools, BlacklistPhonePrefixStore, @@ -79,6 +83,7 @@ runBrigToIO e (AppT ma) = do . interpretJwtTools . interpretPublicKeyBundle . interpretJwk + . interpretFederationDomainConfig (e ^. settings . federationStrategy) (foldMap (remotesMapFromCfgFile . fmap (.federationDomainConfig)) (e ^. settings . federationDomainConfigs)) ) ) $ runReaderT ma e diff --git a/services/brig/src/Brig/Data/Federation.hs b/services/brig/src/Brig/Data/Federation.hs deleted file mode 100644 index 3ae38325d4..0000000000 --- a/services/brig/src/Brig/Data/Federation.hs +++ /dev/null @@ -1,101 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 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 Brig.Data.Federation - ( getFederationRemotes, - addFederationRemote, - updateFederationRemote, - deleteFederationRemote, - addFederationRemoteTeam, - getFederationRemoteTeams, - deleteFederationRemoteTeam, - AddFederationRemoteResult (..), - ) -where - -import Brig.Data.Instances () -import Cassandra -import Control.Exception (ErrorCall (ErrorCall)) -import Control.Monad.Catch (throwM) -import Data.Domain -import Data.Id -import Database.CQL.Protocol (SerialConsistency (LocalSerialConsistency), serialConsistency) -import Imports -import Wire.API.Routes.FederationDomainConfig -import Wire.API.User.Search - -maxKnownNodes :: Int -maxKnownNodes = 10000 - -getFederationRemotes :: forall m. MonadClient m => m [FederationDomainConfig] -getFederationRemotes = (\(d, p, r) -> FederationDomainConfig d p r) <$$> qry - where - qry :: m [(Domain, FederatedUserSearchPolicy, FederationRestriction)] - qry = retry x1 . query get $ params LocalQuorum () - - get :: PrepQuery R () (Domain, FederatedUserSearchPolicy, FederationRestriction) - get = fromString $ "SELECT domain, search_policy, restriction FROM federation_remotes LIMIT " <> show maxKnownNodes - -data AddFederationRemoteResult = AddFederationRemoteSuccess | AddFederationRemoteMaxRemotesReached - -addFederationRemote :: MonadClient m => FederationDomainConfig -> m AddFederationRemoteResult -addFederationRemote (FederationDomainConfig rDomain searchPolicy restriction) = do - l <- length <$> getFederationRemotes - if l >= maxKnownNodes - then pure AddFederationRemoteMaxRemotesReached - else AddFederationRemoteSuccess <$ retry x5 (write add (params LocalQuorum (rDomain, searchPolicy, restriction))) - where - add :: PrepQuery W (Domain, FederatedUserSearchPolicy, FederationRestriction) () - add = "INSERT INTO federation_remotes (domain, search_policy, restriction) VALUES (?, ?, ?)" - -updateFederationRemote :: MonadClient m => FederationDomainConfig -> m Bool -updateFederationRemote (FederationDomainConfig rDomain searchPolicy restriction) = do - retry x1 (trans upd (params LocalQuorum (searchPolicy, restriction, rDomain)) {serialConsistency = Just LocalSerialConsistency}) >>= \case - [] -> pure False - [_] -> pure True - _ -> throwM $ ErrorCall "Primary key violation detected federation_remotes" - where - upd :: PrepQuery W (FederatedUserSearchPolicy, FederationRestriction, Domain) x - upd = "UPDATE federation_remotes SET search_policy = ?, restriction = ? WHERE domain = ? IF EXISTS" - -deleteFederationRemote :: MonadClient m => Domain -> m () -deleteFederationRemote rDomain = - retry x1 $ write delete (params LocalQuorum (Identity rDomain)) - where - delete :: PrepQuery W (Identity Domain) () - delete = "DELETE FROM federation_remotes WHERE domain = ?" - -addFederationRemoteTeam :: MonadClient m => Domain -> FederationRemoteTeam -> m () -addFederationRemoteTeam rDomain rteam = - retry x1 $ write add (params LocalQuorum (rDomain, rteam.teamId)) - where - add :: PrepQuery W (Domain, TeamId) () - add = "INSERT INTO federation_remote_teams (domain, team) VALUES (?, ?)" - -getFederationRemoteTeams :: MonadClient m => Domain -> m [FederationRemoteTeam] -getFederationRemoteTeams rDomain = do - fmap (FederationRemoteTeam . runIdentity) <$> retry x1 (query get (params LocalQuorum (Identity rDomain))) - where - get :: PrepQuery R (Identity Domain) (Identity TeamId) - get = "SELECT team FROM federation_remote_teams WHERE domain = ?" - -deleteFederationRemoteTeam :: MonadClient m => Domain -> TeamId -> m () -deleteFederationRemoteTeam rDomain rteam = - retry x1 $ write delete (params LocalQuorum (rDomain, rteam)) - where - delete :: PrepQuery W (Domain, TeamId) () - delete = "DELETE FROM federation_remote_teams WHERE domain = ? AND team = ?" diff --git a/services/brig/src/Brig/Data/Instances.hs b/services/brig/src/Brig/Data/Instances.hs index 47f0ccf337..3430931577 100644 --- a/services/brig/src/Brig/Data/Instances.hs +++ b/services/brig/src/Brig/Data/Instances.hs @@ -41,7 +41,6 @@ import Wire.API.Asset (AssetKey, assetKeyToText, nilAssetKey) import Wire.API.Connection (RelationWithHistory (..)) import Wire.API.MLS.CipherSuite import Wire.API.Properties -import Wire.API.Routes.FederationDomainConfig (FederationRestriction (..)) import Wire.API.User import Wire.API.User.Activation import Wire.API.User.Client @@ -302,16 +301,6 @@ instance Cql FederatedUserSearchPolicy where fromCql (CqlInt 2) = pure FullSearch fromCql n = Left $ "Unexpected SearchVisibilityInbound: " ++ show n -instance Cql FederationRestriction where - ctype = Tagged IntColumn - - toCql FederationRestrictionAllowAll = CqlInt 0 - toCql FederationRestrictionByTeam = CqlInt 1 - - fromCql (CqlInt 0) = pure FederationRestrictionAllowAll - fromCql (CqlInt 1) = pure FederationRestrictionByTeam - fromCql n = Left $ "Unexpected FederationRestriction: " ++ show n - instance Cql (Imports.Set BaseProtocolTag) where ctype = Tagged IntColumn diff --git a/services/brig/src/Brig/Effects/FederationConfigStore.hs b/services/brig/src/Brig/Effects/FederationConfigStore.hs new file mode 100644 index 0000000000..94c67b6112 --- /dev/null +++ b/services/brig/src/Brig/Effects/FederationConfigStore.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE TemplateHaskell #-} + +module Brig.Effects.FederationConfigStore where + +import Data.Domain +import Data.Id +import Imports +import Polysemy +import Wire.API.Routes.FederationDomainConfig + +data AddFederationRemoteResult + = AddFederationRemoteSuccess + | AddFederationRemoteMaxRemotesReached + | AddFederationRemoteDivergingConfig (Map Domain FederationDomainConfig) + +data UpdateFederationResult + = UpdateFederationSuccess + | UpdateFederationRemoteNotFound + | UpdateFederationRemoteDivergingConfig + +data AddFederationRemoteTeamResult + = AddFederationRemoteTeamSuccess + | AddFederationRemoteTeamDomainNotFound + | AddFederationRemoteTeamRestrictionAllowAll + +data FederationConfigStore m a where + GetFederationConfig :: Domain -> FederationConfigStore m (Maybe FederationDomainConfig) + GetFederationConfigs :: FederationConfigStore m FederationDomainConfigs + AddFederationConfig :: FederationDomainConfig -> FederationConfigStore m AddFederationRemoteResult + UpdateFederationConfig :: FederationDomainConfig -> FederationConfigStore m UpdateFederationResult + AddFederationRemoteTeam :: Domain -> TeamId -> FederationConfigStore m AddFederationRemoteTeamResult + RemoveFederationRemoteTeam :: Domain -> TeamId -> FederationConfigStore m () + GetFederationRemoteTeams :: Domain -> FederationConfigStore m [FederationRemoteTeam] + +makeSem ''FederationConfigStore diff --git a/services/brig/src/Brig/Effects/FederationConfigStore/Cassandra.hs b/services/brig/src/Brig/Effects/FederationConfigStore/Cassandra.hs new file mode 100644 index 0000000000..734a22ba17 --- /dev/null +++ b/services/brig/src/Brig/Effects/FederationConfigStore/Cassandra.hs @@ -0,0 +1,242 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 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 Brig.Effects.FederationConfigStore.Cassandra + ( interpretFederationDomainConfig, + remotesMapFromCfgFile, + AddFederationRemoteResult (..), + ) +where + +import Brig.Data.Instances () +import Brig.Effects.FederationConfigStore +import Cassandra +import Control.Exception (ErrorCall (ErrorCall)) +import Control.Lens +import Control.Monad.Catch (throwM) +import Data.Domain +import Data.Id +import Data.Map qualified as Map +import Database.CQL.Protocol (SerialConsistency (LocalSerialConsistency), serialConsistency) +import Imports +import Polysemy +import Wire.API.Routes.FederationDomainConfig +import Wire.API.User.Search + +-- | Interpreter for getting the federation config from the database and the config file. +-- The config file is injected into the interpreter and has precedence over the database. +-- The config file is static and can only be changed by restarting the service. +-- If a domain is configured in the config file, it is not allowed to add it to the database. +-- If a domain is configured in the config file, it is not allowed to update it in the database. +-- If a domain is configured in the config file, it is not allowed to add a team restriction to it in the database. +-- In the future the config file will be removed and the database will be the only source of truth. +interpretFederationDomainConfig :: + forall m r a. + ( MonadClient m, + Member (Embed m) r + ) => + Maybe FederationStrategy -> + Map Domain FederationDomainConfig -> + Sem (FederationConfigStore ': r) a -> + Sem r a +interpretFederationDomainConfig mFedStrategy fedCfgs = + interpret $ + embed @m . \case + GetFederationConfig d -> getFederationConfig' fedCfgs d + GetFederationConfigs -> getFederationConfigs' mFedStrategy fedCfgs + AddFederationConfig cnf -> addFederationConfig' fedCfgs cnf + UpdateFederationConfig cnf -> updateFederationConfig' fedCfgs cnf + AddFederationRemoteTeam d t -> addFederationRemoteTeam' fedCfgs d t + RemoveFederationRemoteTeam d t -> removeFederationRemoteTeam' d t + GetFederationRemoteTeams d -> getFederationRemoteTeams' d + +-- | Compile config file list into a map indexed by domains. Use this to make sure the config +-- file is consistent (ie., no two entries for the same domain). +-- This is called during initialization of the interpreter and the service will fail if the config is not consistent. +remotesMapFromCfgFile :: [FederationDomainConfig] -> Map Domain FederationDomainConfig +remotesMapFromCfgFile cfg = + let dict = [(cnf.domain, cnf) | cnf <- cfg] + merge c c' = + if c == c' + then c + else error $ "error in config file: conflicting parameters on domain: " <> show (c, c') + in Map.fromListWith merge dict + +getFederationConfigs' :: forall m. (MonadClient m) => Maybe FederationStrategy -> Map Domain FederationDomainConfig -> m FederationDomainConfigs +getFederationConfigs' mFedStrategy cfgs = do + -- FUTUREWORK: we should solely rely on `db` in the future for remote domains; merging + -- remote domains from `cfg` is just for providing an easier, more robust migration path. + -- See + -- https://docs.wire.com/understand/federation/backend-communication.html#configuring-remote-connections, + -- http://docs.wire.com/developer/developer/federation-design-aspects.html#configuring-remote-connections-dev-perspective + -- (because the creation and update of a federation config is guarded, we can safely merge the two configs here) + remotes <- + (<>) + <$> getFederationRemotesFromDb + <*> pure (Map.elems cfgs) + + defFederationDomainConfigs + & maybe id (\v cfg -> cfg {strategy = v}) mFedStrategy + & (\cfg -> cfg {remotes = remotes}) + & pure + +maxKnownNodes :: Int +maxKnownNodes = 10000 + +getFederationConfig' :: MonadClient m => Map Domain FederationDomainConfig -> Domain -> m (Maybe FederationDomainConfig) +getFederationConfig' cfgs rDomain = case find ((== rDomain) . domain) cfgs of + Just cfg -> pure . Just $ cfg -- the configuration from the file has precedence (if exists there should not be a db entry at all) + Nothing -> do + mCnf <- retry x1 (query1 q (params LocalQuorum (Identity rDomain))) + case mCnf of + Just (p, r) -> Just . FederationDomainConfig rDomain p <$> toRestriction rDomain r + Nothing -> pure Nothing + where + q :: PrepQuery R (Identity Domain) (FederatedUserSearchPolicy, Int32) + q = "SELECT search_policy, restriction FROM federation_remotes WHERE domain = ?" + +getFederationRemotesFromDb :: forall m. MonadClient m => m [FederationDomainConfig] +getFederationRemotesFromDb = (\(d, p, r) -> FederationDomainConfig d p r) <$$> qry + where + qry :: m [(Domain, FederatedUserSearchPolicy, FederationRestriction)] + qry = do + res <- retry x1 . query get $ params LocalQuorum () + forM res $ \(d, p, rInt) -> do + (d,p,) <$> toRestriction d rInt + + get :: PrepQuery R () (Domain, FederatedUserSearchPolicy, Int32) + get = fromString $ "SELECT domain, search_policy, restriction FROM federation_remotes LIMIT " <> show maxKnownNodes + +addFederationConfig' :: MonadClient m => Map Domain FederationDomainConfig -> FederationDomainConfig -> m AddFederationRemoteResult +addFederationConfig' cfg (FederationDomainConfig rDomain searchPolicy restriction) = do + -- if a domain already exists in a config, we do not allow to add it to the database + conflict <- domainExistsInConfig (FederationDomainConfig rDomain searchPolicy restriction) + if conflict + then pure $ AddFederationRemoteDivergingConfig cfg + else do + l <- length <$> getFederationRemotesFromDb + if l >= maxKnownNodes + then pure AddFederationRemoteMaxRemotesReached + else + AddFederationRemoteSuccess <$ do + retry x5 (write addConfig (params LocalQuorum (rDomain, searchPolicy, fromRestriction restriction))) + case restriction of + FederationRestrictionByTeam tids -> + retry x5 . batch . forM_ tids $ addPrepQuery addTeams . (rDomain,) + FederationRestrictionAllowAll -> pure () + where + -- If remote domain is registered in config file, the version that can be added to the + -- database must be the same. + domainExistsInConfig :: (Monad m) => FederationDomainConfig -> m Bool + domainExistsInConfig fedDomConf = do + pure $ case Map.lookup (domain fedDomConf) cfg of + Nothing -> False + Just fedDomConf' -> fedDomConf' /= fedDomConf + + addConfig :: PrepQuery W (Domain, FederatedUserSearchPolicy, Int32) () + addConfig = "INSERT INTO federation_remotes (domain, search_policy, restriction) VALUES (?, ?, ?)" + + addTeams :: PrepQuery W (Domain, TeamId) () + addTeams = "INSERT INTO federation_remote_teams (domain, team) VALUES (?, ?)" + +updateFederationConfig' :: MonadClient m => Map Domain FederationDomainConfig -> FederationDomainConfig -> m UpdateFederationResult +updateFederationConfig' cfgs (FederationDomainConfig rDomain searchPolicy restriction) = do + -- if a domain already exists in a config, we do not allow update it + if rDomain `elem` (domain <$> cfgs) + then pure UpdateFederationRemoteDivergingConfig + else do + let configParams = + ( params + LocalQuorum + (searchPolicy, fromRestriction restriction, rDomain) + ) + { serialConsistency = Just LocalSerialConsistency + } + r <- retry x1 (trans updateConfig configParams) + updateTeams + case r of + [] -> pure UpdateFederationRemoteNotFound + [_] -> pure UpdateFederationSuccess + _ -> throwM $ ErrorCall "Primary key violation detected federation_remotes" + where + updateConfig :: PrepQuery W (FederatedUserSearchPolicy, Int32, Domain) x + updateConfig = "UPDATE federation_remotes SET search_policy = ?, restriction = ? WHERE domain = ? IF EXISTS" + + updateTeams :: MonadClient m => m () + updateTeams = retry x5 $ do + write dropTeams (params LocalQuorum (Identity rDomain)) + case restriction of + FederationRestrictionByTeam tids -> + batch . forM_ tids $ addPrepQuery insertTeam . (rDomain,) + FederationRestrictionAllowAll -> pure () + + dropTeams :: PrepQuery W (Identity Domain) () + dropTeams = "DELETE FROM federation_remote_teams WHERE domain = ?" + + insertTeam :: PrepQuery W (Domain, TeamId) () + insertTeam = "INSERT INTO federation_remote_teams (domain, team) VALUES (?, ?)" + +addFederationRemoteTeam' :: MonadClient m => Map Domain FederationDomainConfig -> Domain -> TeamId -> m AddFederationRemoteTeamResult +addFederationRemoteTeam' cfgs rDomain tid = do + mDom <- getFederationConfig' cfgs rDomain + case mDom of + Nothing -> + pure AddFederationRemoteTeamDomainNotFound + Just (FederationDomainConfig _ _ FederationRestrictionAllowAll) -> + pure AddFederationRemoteTeamRestrictionAllowAll + Just _ -> do + retry x1 $ write add (params LocalQuorum (rDomain, tid)) + pure AddFederationRemoteTeamSuccess + where + add :: PrepQuery W (Domain, TeamId) () + add = "INSERT INTO federation_remote_teams (domain, team) VALUES (?, ?)" + +getFederationRemoteTeams' :: MonadClient m => Domain -> m [FederationRemoteTeam] +getFederationRemoteTeams' rDomain = do + fmap (FederationRemoteTeam . runIdentity) <$> retry x1 (query get (params LocalQuorum (Identity rDomain))) + where + get :: PrepQuery R (Identity Domain) (Identity TeamId) + get = "SELECT team FROM federation_remote_teams WHERE domain = ?" + +removeFederationRemoteTeam' :: MonadClient m => Domain -> TeamId -> m () +removeFederationRemoteTeam' rDomain rteam = + retry x1 $ write delete (params LocalQuorum (rDomain, rteam)) + where + delete :: PrepQuery W (Domain, TeamId) () + delete = "DELETE FROM federation_remote_teams WHERE domain = ? AND team = ?" + +data RestrictionException = RestrictionException Int32 + +instance Show RestrictionException where + show (RestrictionException v) = + "Expected a RestrictionPolicy encoding, but found a value " <> show v + +instance Exception RestrictionException + +toRestriction :: MonadClient m => Domain -> Int32 -> m FederationRestriction +toRestriction _ 0 = pure FederationRestrictionAllowAll +toRestriction dom 1 = + fmap FederationRestrictionByTeam $ + runIdentity <$$> retry x1 (query getTeams (params LocalQuorum (Identity dom))) + where + getTeams :: PrepQuery R (Identity Domain) (Identity TeamId) + getTeams = fromString $ "SELECT team FROM federation_remote_teams WHERE domain = ?" +toRestriction _ v = throwM . RestrictionException $ v + +fromRestriction :: FederationRestriction -> Int32 +fromRestriction FederationRestrictionAllowAll = 0 +fromRestriction (FederationRestrictionByTeam _) = 1 diff --git a/services/brig/src/Brig/Options.hs b/services/brig/src/Brig/Options.hs index 7f70cb1be3..dac8446d2a 100644 --- a/services/brig/src/Brig/Options.hs +++ b/services/brig/src/Brig/Options.hs @@ -928,6 +928,7 @@ Lens.makeLensesFor ("setSftStaticUrl", "sftStaticUrl"), ("setSftListAllServers", "sftListAllServers"), ("setFederationDomainConfigs", "federationDomainConfigs"), + ("setFederationStrategy", "federationStrategy"), ("setEnableDevelopmentVersions", "enableDevelopmentVersions"), ("setRestrictUserCreation", "restrictUserCreation"), ("setEnableMLS", "enableMLS"), diff --git a/services/brig/src/Brig/User/API/Search.hs b/services/brig/src/Brig/User/API/Search.hs index 43d3a76070..44a87a7dd5 100644 --- a/services/brig/src/Brig/User/API/Search.hs +++ b/services/brig/src/Brig/User/API/Search.hs @@ -28,6 +28,8 @@ import Brig.API.Error (fedError) import Brig.API.Handler import Brig.App import Brig.Data.User qualified as DB +import Brig.Effects.FederationConfigStore +import Brig.Effects.FederationConfigStore qualified as E import Brig.Effects.GalleyProvider (GalleyProvider) import Brig.Effects.GalleyProvider qualified as GalleyProvider import Brig.Federation.Client qualified as Federation @@ -52,6 +54,7 @@ import System.Logger.Class (val, (~~)) import System.Logger.Class qualified as Log import Wire.API.Federation.API.Brig qualified as FedBrig import Wire.API.Federation.API.Brig qualified as S +import Wire.API.Routes.FederationDomainConfig import Wire.API.Team.Permission qualified as Public import Wire.API.Team.SearchVisibility (TeamSearchVisibility (..)) import Wire.API.User.Search @@ -60,7 +63,9 @@ import Wire.API.User.Search qualified as Public -- FUTUREWORK: Consider augmenting 'SearchResult' with full user profiles -- for all results. This is tracked in https://wearezeta.atlassian.net/browse/SQCORE-599 search :: - (Member GalleyProvider r) => + ( Member GalleyProvider r, + Member FederationConfigStore r + ) => UserId -> Text -> Maybe Domain -> @@ -73,18 +78,26 @@ search searcherId searchTerm maybeDomain maybeMaxResults = do -- looked up again. ensurePermissionsOrPersonalUser searcherId [SearchContacts] federationDomain <- viewFederationDomain + mSearcherTeamId <- lift $ wrapClient $ DB.lookupUserTeam searcherId let queryDomain = fromMaybe federationDomain maybeDomain if queryDomain == federationDomain then searchLocally searcherId searchTerm maybeMaxResults - else searchRemotely queryDomain searchTerm + else searchRemotely queryDomain mSearcherTeamId searchTerm -searchRemotely :: Domain -> Text -> (Handler r) (Public.SearchResult Public.Contact) -searchRemotely domain searchTerm = do +searchRemotely :: (Member FederationConfigStore r) => Domain -> Maybe TeamId -> Text -> (Handler r) (Public.SearchResult Public.Contact) +searchRemotely domain mTid searchTerm = do lift . Log.info $ msg (val "searchRemotely") ~~ field "domain" (show domain) ~~ field "searchTerm" searchTerm - searchResponse <- Federation.searchUsers domain (FedBrig.SearchRequest searchTerm) !>> fedError + mFedCnf <- lift $ liftSem $ E.getFederationConfig domain + let onlyInTeams = case restriction <$> mFedCnf of + Just FederationRestrictionAllowAll -> Nothing + Just (FederationRestrictionByTeam teams) -> Just teams + -- if we are not federating at all, we also do not allow to search any remote teams + Nothing -> Just [] + + searchResponse <- Federation.searchUsers domain (FedBrig.SearchRequest searchTerm mTid onlyInTeams) !>> fedError let contacts = S.contacts searchResponse let count = length contacts pure diff --git a/services/brig/src/Brig/User/Search/SearchIndex.hs b/services/brig/src/Brig/User/Search/SearchIndex.hs index 53c633ff4c..b70fcf3deb 100644 --- a/services/brig/src/Brig/User/Search/SearchIndex.hs +++ b/services/brig/src/Brig/User/Search/SearchIndex.hs @@ -44,14 +44,14 @@ import Wire.API.User.Search -- Team of user that is performing the search -- Outgoing search restrictions data SearchSetting - = FederatedSearch + = FederatedSearch (Maybe [TeamId]) | LocalSearch UserId (Maybe TeamId) TeamSearchInfo searchSettingTeam :: SearchSetting -> Maybe TeamId -searchSettingTeam FederatedSearch = Nothing +searchSettingTeam (FederatedSearch _) = Nothing searchSettingTeam (LocalSearch _ mbTeam _) = mbTeam searchIndex :: @@ -186,12 +186,12 @@ termQ f v = Nothing matchSelf :: SearchSetting -> Maybe ES.Query -matchSelf FederatedSearch = Nothing +matchSelf (FederatedSearch _) = Nothing matchSelf (LocalSearch searcher _tid _searchInfo) = Just (termQ "_id" (review _TextId searcher)) -- | See 'TeamSearchInfo' restrictSearchSpace :: SearchSetting -> ES.Query -restrictSearchSpace FederatedSearch = +restrictSearchSpace (FederatedSearch Nothing) = ES.QueryBoolQuery boolQuery { ES.boolQueryShouldMatch = @@ -199,6 +199,25 @@ restrictSearchSpace FederatedSearch = matchTeamMembersSearchableByAllTeams ] } +restrictSearchSpace (FederatedSearch (Just [])) = + ES.QueryBoolQuery + boolQuery + { ES.boolQueryMustMatch = + [ -- if the list of allowed teams is empty, this is impossible to fulfill, and no results will be returned + -- this case should be handled earlier, so this is just a safety net + ES.TermQuery (ES.Term "team" "must not match any team") Nothing + ] + } +restrictSearchSpace (FederatedSearch (Just teams)) = + ES.QueryBoolQuery + boolQuery + { ES.boolQueryMustMatch = + [ matchTeamMembersSearchableByAllTeams, + onlyInTeams + ] + } + where + onlyInTeams = ES.QueryBoolQuery boolQuery {ES.boolQueryShouldMatch = map matchTeamMembersOf teams} restrictSearchSpace (LocalSearch _uid mteam searchInfo) = case (mteam, searchInfo) of (Nothing, _) -> matchNonTeamMemberUsers @@ -216,8 +235,9 @@ restrictSearchSpace (LocalSearch _uid mteam searchInfo) = matchTeamMembersOf searcherTeam ] } - where - matchTeamMembersOf team = ES.TermQuery (ES.Term "team" $ idToText team) Nothing + +matchTeamMembersOf :: TeamId -> ES.Query +matchTeamMembersOf team = ES.TermQuery (ES.Term "team" $ idToText team) Nothing matchTeamMembersSearchableByAllTeams :: ES.Query matchTeamMembersSearchableByAllTeams = diff --git a/services/brig/test/integration/API/Federation.hs b/services/brig/test/integration/API/Federation.hs index ddaf9dc9b7..7dd448eb30 100644 --- a/services/brig/test/integration/API/Federation.hs +++ b/services/brig/test/integration/API/Federation.hs @@ -98,7 +98,7 @@ testSearchSuccess opts brig = do searchResponse <- withSettingsOverrides (allowFullSearch domain opts) $ do runWaiTestFedClient domain $ createWaiTestFedClient @"search-users" @'Brig $ - SearchRequest (fromHandle handle) + SearchRequest (fromHandle handle) Nothing Nothing liftIO $ do let contacts = contactQualifiedId <$> S.contacts searchResponse @@ -115,7 +115,7 @@ testFulltextSearchSuccess opts brig = do searchResponse <- withSettingsOverrides (allowFullSearch domain opts) $ do runWaiTestFedClient domain $ createWaiTestFedClient @"search-users" @'Brig $ - SearchRequest (fromName $ userDisplayName user) + SearchRequest (fromName $ userDisplayName user) Nothing Nothing liftIO $ do let contacts = contactQualifiedId <$> S.contacts searchResponse @@ -142,7 +142,7 @@ testFulltextSearchMultipleUsers opts brig = do searchResponse <- withSettingsOverrides (allowFullSearch domain opts) $ do runWaiTestFedClient domain $ createWaiTestFedClient @"search-users" @'Brig $ - SearchRequest (fromHandle handle) + SearchRequest (fromHandle handle) Nothing Nothing liftIO $ do let contacts = contactQualifiedId <$> S.contacts searchResponse @@ -155,7 +155,7 @@ testSearchNotFound opts = do searchResponse <- withSettingsOverrides (allowFullSearch domain opts) $ do runWaiTestFedClient domain $ createWaiTestFedClient @"search-users" @'Brig $ - SearchRequest "this-handle-should-not-exist" + SearchRequest "this-handle-should-not-exist" Nothing Nothing liftIO $ assertEqual "should return empty array of users" [] (S.contacts searchResponse) @@ -166,7 +166,7 @@ testSearchNotFoundEmpty opts = do searchResponse <- withSettingsOverrides (allowFullSearch domain opts) $ do runWaiTestFedClient domain $ createWaiTestFedClient @"search-users" @'Brig $ - SearchRequest "this-handle-should-not-exist" + SearchRequest "this-handle-should-not-exist" Nothing Nothing liftIO $ assertEqual "should return empty array of users" [] (S.contacts searchResponse) @@ -194,7 +194,7 @@ testSearchRestrictions opts brig = do let squery = either fromHandle fromName handleOrName searchResponse <- runWaiTestFedClient domain $ - createWaiTestFedClient @"search-users" @'Brig (SearchRequest squery) + createWaiTestFedClient @"search-users" @'Brig (SearchRequest squery Nothing Nothing) liftIO $ do case (mExpectedUser, handleOrName) of (Just expectedUser, Right _) ->