From 23c6738a7251af9cff1302daed57446074c95067 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 13 Aug 2026 15:43:31 +0200 Subject: [PATCH 01/15] Changelog, failing integration tests. --- ...nt-apps-and-collaborators-as-members-in-get-team-size | 1 + integration/test/Test/Apps.hs | 7 +++++++ integration/test/Test/TeamCollaborators.hs | 9 ++++++++- 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 changelog.d/1-api-changes/WPB-27169-do-not-count-apps-and-collaborators-as-members-in-get-team-size diff --git a/changelog.d/1-api-changes/WPB-27169-do-not-count-apps-and-collaborators-as-members-in-get-team-size b/changelog.d/1-api-changes/WPB-27169-do-not-count-apps-and-collaborators-as-members-in-get-team-size new file mode 100644 index 00000000000..a052dda2913 --- /dev/null +++ b/changelog.d/1-api-changes/WPB-27169-do-not-count-apps-and-collaborators-as-members-in-get-team-size @@ -0,0 +1 @@ +Do not count apps and collaborators as members in get-team-size. New schema: `{"teamSize": num, "apps": num, "collaborators": num}` (non-overlapping). `teamSize` has been the label since the dawn of time, only the other two have changed. The protobuf schema for TeamEvents changed accordingly. diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index 4428f5e732f..91080552b96 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -68,6 +68,13 @@ testCreateGetApp sameOrOtherDomain = do void $ assertNoEvent 5 wsRegularMember pure (appId, cookie) + -- team size counts apps separately. (they are not members.) + bindResponse (getTeamSize owner tid) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "teamSize" `shouldMatchInt` 2 + resp.json %. "apps" `shouldMatchInt` 1 + resp.json %. "collaborators" `shouldMatchInt` 0 + -- Verify that the team.member-join event is in the team notifications queue bindResponse (getTeamNotifications regularMember (Just lastTeamNotif)) $ \resp -> do resp.status `shouldMatchInt` 200 diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index 642dad537e8..96192faaa72 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -19,7 +19,7 @@ module Test.TeamCollaborators where -import qualified API.Brig as BrigP +import API.Brig as BrigP import qualified API.BrigInternal as BrigI import API.Common (randomName) import API.Galley @@ -63,6 +63,13 @@ testCreateTeamCollaborator = do res %. "team" `shouldMatch` team res %. "permissions" `shouldMatch` ["create_team_conversation", "implicit_connection"] + -- team size counts collaborators separately + bindResponse (getTeamSize owner team) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "teamSize" `shouldMatchInt` 2 + resp.json %. "apps" `shouldMatchInt` 0 + resp.json %. "collaborators" `shouldMatchInt` 1 + testTeamCollaboratorEndpointsForbiddenForOtherTeams :: (HasCallStack) => App () testTeamCollaboratorEndpointsForbiddenForOtherTeams = do (owner, _team, _members) <- createTeam OwnDomain 2 From 6b3523379798e52b792709a9182880a7dc1f543a Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Fri, 14 Aug 2026 09:17:47 +0200 Subject: [PATCH 02/15] [drive-by] regenerated wire-api golden tests. --- .../golden/testObject_Event_meeting_create_manual_1.json | 8 ++++---- .../golden/testObject_Event_meeting_delete_manual_1.json | 8 ++++---- .../testObject_Event_meeting_member_add_manual_1.json | 8 ++++---- .../testObject_Event_meeting_member_add_manual_2.json | 8 ++++---- .../golden/testObject_Event_meeting_update_manual_1.json | 8 ++++---- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json index faaf77d4b6a..7aac908df04 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "time": "2018-01-01T00:00:00.000Z", "type": "meeting.create", "via": "user" diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json index 5bae8ab62d0..6ff021670ec 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "time": "2018-01-01T00:00:00.000Z", "type": "meeting.delete", "via": "user" diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json index 8d40ebe09a2..cad3c3e2768 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "time": "2018-01-01T00:00:00.000Z", "type": "meeting.member-add", "via": "user" diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json index 628f1bf141e..00f1ce4f3bc 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "team": "00000002-0000-0000-0000-000000000002", "time": "2018-01-01T00:00:00.000Z", "type": "meeting.member-add", diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json index e1754d221ef..42c3c2c3780 100644 --- a/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json +++ b/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json @@ -1,9 +1,5 @@ { "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", - "qualified_id": { - "domain": "example.com", - "id": "00000001-0000-0000-0000-000000000001" - }, "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", "qualified_conversation": { "domain": "example.com", @@ -13,6 +9,10 @@ "domain": "example.com", "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, "time": "2018-01-01T00:00:00.000Z", "type": "meeting.update", "via": "user" From 6e1261fb2683dd6655f0dccd1cb66b0ec94b5ad7 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 26 Aug 2026 14:58:39 +0200 Subject: [PATCH 03/15] Do not count apps and collaborators as members in get-team-size. New schema: `{"teamSize": num, "apps": num, "collaborators": num}` (non-overlapping). --- .../proto/TeamEvents.proto | 7 ++- libs/wire-api/src/Wire/API/Team/Size.hs | 51 ++++--------------- .../Test/Wire/API/Golden/Manual/TeamSize.hs | 6 +-- .../test/golden/testObject_TeamSize_1.json | 6 +-- .../test/golden/testObject_TeamSize_2.json | 6 +-- .../test/golden/testObject_TeamSize_3.json | 6 +-- .../Wire/IndexedUserStore/ElasticSearch.hs | 2 +- libs/wire-subsystems/src/Wire/TeamJournal.hs | 21 ++++---- .../src/Wire/UserSubsystem/Interpreter.hs | 4 +- .../Wire/MockInterpreters/IndexedUserStore.hs | 6 ++- services/brig/test/integration/API/Team.hs | 2 +- .../galley/src/Galley/API/LegalHold/Team.hs | 11 ++-- services/galley/src/Galley/API/Teams.hs | 33 ++++++------ 13 files changed, 65 insertions(+), 96 deletions(-) diff --git a/libs/types-common-journal/proto/TeamEvents.proto b/libs/types-common-journal/proto/TeamEvents.proto index 8bd25c21cc8..0552704b7a4 100644 --- a/libs/types-common-journal/proto/TeamEvents.proto +++ b/libs/types-common-journal/proto/TeamEvents.proto @@ -22,10 +22,9 @@ message TeamEvent { // are guaranteed to be present). // // for backwards compatibility, clients should make these - // fields optional, and fall back to using `member_count` if - // they are missing. - required int32 member_count_regular = 4; - required int32 member_count_app = 5; + // fields optional, and assume '0' if missing. + required int32 apps = 4; + required int32 collaborators = 5; } enum EventType { diff --git a/libs/wire-api/src/Wire/API/Team/Size.hs b/libs/wire-api/src/Wire/API/Team/Size.hs index d751769a903..ba84e40039f 100644 --- a/libs/wire-api/src/Wire/API/Team/Size.hs +++ b/libs/wire-api/src/Wire/API/Team/Size.hs @@ -17,66 +17,33 @@ module Wire.API.Team.Size ( TeamSize (..), - teamSizeTotal, - updateTeamSize, ) where import Control.Lens ((?~)) import Data.Aeson qualified as A -import Data.Aeson.Types qualified as A import Data.OpenApi qualified as S import Data.Schema import Imports import Numeric.Natural import Test.QuickCheck (arbitrarySizedNatural) -import Wire.API.User.Search import Wire.Arbitrary data TeamSize = TeamSize - { regulars :: Natural, - apps :: Natural + { teamSize :: Natural, + apps :: Natural, + collaborators :: Natural } deriving (Show, Eq) deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema TeamSize) --- | Total team members (regulars + apps). -teamSizeTotal :: TeamSize -> Natural -teamSizeTotal ts = ts.regulars + ts.apps - --- Increase or decrease a team size component, depending on user type. - --- If the result of a decrease is <0, it is set to 1 (regulars) or 0 --- (apps). This handles corner cases where ES reports lower numbers --- from the past. -updateTeamSize :: UserTypeFilter -> TeamSize -> Int -> TeamSize -updateTeamSize = go - where - go :: UserTypeFilter -> TeamSize -> Int -> TeamSize - go UserTypeFilterRegular (TeamSize rs as) n = TeamSize (upd 1 rs n) as - go UserTypeFilterApp (TeamSize rs as) n = TeamSize rs (upd 0 as n) - - upd :: Int -> Natural -> Int -> Natural - upd low n i = fromIntegral . max low $ fromIntegral n + i - instance ToSchema TeamSize where schema = - objectWithDocModifier (description ?~ "Team member counts broken down by user type.") $ - fromTeamSize .= tripleSchema `withParser` validate - where - fromTeamSize :: TeamSize -> (Natural, Natural, Maybe Natural) - fromTeamSize ts = (ts.regulars, ts.apps, Just (teamSizeTotal ts)) - tripleSchema :: ObjectSchema SwaggerDoc (Natural, Natural, Maybe Natural) - tripleSchema = - (,,) - <$> (\(r, _, _) -> r) .= fieldWithDocModifier "teamSizeRegulars" (description ?~ "Number of regular users in team.") schema - <*> (\(_, a, _) -> a) .= fieldWithDocModifier "teamSizeApps" (description ?~ "Number of apps in team.") schema - <*> (\(_, _, t) -> t) .= maybe_ (optFieldWithDocModifier "teamSize" (description ?~ "Total team members (teamSizeRegulars + teamSizeApps).") schema) - validate :: (Natural, Natural, Maybe Natural) -> A.Parser TeamSize - validate (r, a, Nothing) = pure TeamSize {regulars = r, apps = a} - validate (r, a, Just t) - | r + a == t = pure TeamSize {regulars = r, apps = a} - | otherwise = fail $ "teamSize (" <> show t <> ") != regulars + apps (" <> show (r + a) <> ")" + objectWithDocModifier (description ?~ "Number of team members (paid seats, always regular users), appps, collaborators.") $ + TeamSize + <$> (.teamSize) .= field "teamSize" schema + <*> (.apps) .= field "apps" schema + <*> (.collaborators) .= field "collaborators" schema instance Arbitrary TeamSize where - arbitrary = TeamSize <$> arbitrarySizedNatural <*> arbitrarySizedNatural + arbitrary = TeamSize <$> arbitrarySizedNatural <*> arbitrarySizedNatural <*> arbitrarySizedNatural diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs index 8137fe05501..82757e4e94d 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/TeamSize.hs @@ -21,10 +21,10 @@ import Imports import Wire.API.Team.Size testObject_TeamSize_1 :: TeamSize -testObject_TeamSize_1 = TeamSize 0 0 +testObject_TeamSize_1 = TeamSize 0 0 0 testObject_TeamSize_2 :: TeamSize -testObject_TeamSize_2 = TeamSize 100 400 +testObject_TeamSize_2 = TeamSize 100 400 7 testObject_TeamSize_3 :: TeamSize -testObject_TeamSize_3 = TeamSize (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64) +testObject_TeamSize_3 = TeamSize (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64) (fromIntegral $ maxBound @Word64) diff --git a/libs/wire-api/test/golden/testObject_TeamSize_1.json b/libs/wire-api/test/golden/testObject_TeamSize_1.json index 92dda71f2da..e76772592dc 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_1.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_1.json @@ -1,5 +1,5 @@ { - "teamSize": 0, - "teamSizeApps": 0, - "teamSizeRegulars": 0 + "apps": 0, + "collaborators": 0, + "teamSize": 0 } diff --git a/libs/wire-api/test/golden/testObject_TeamSize_2.json b/libs/wire-api/test/golden/testObject_TeamSize_2.json index 5b9794591db..293cfd45e5f 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_2.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_2.json @@ -1,5 +1,5 @@ { - "teamSize": 500, - "teamSizeApps": 400, - "teamSizeRegulars": 100 + "apps": 400, + "collaborators": 7, + "teamSize": 100 } diff --git a/libs/wire-api/test/golden/testObject_TeamSize_3.json b/libs/wire-api/test/golden/testObject_TeamSize_3.json index 421801b4b47..2145f501bf3 100644 --- a/libs/wire-api/test/golden/testObject_TeamSize_3.json +++ b/libs/wire-api/test/golden/testObject_TeamSize_3.json @@ -1,5 +1,5 @@ { - "teamSize": 3.689348814741910323e19, - "teamSizeApps": 1.8446744073709551615e19, - "teamSizeRegulars": 1.8446744073709551615e19 + "apps": 1.8446744073709551615e19, + "collaborators": 1.8446744073709551615e19, + "teamSize": 1.8446744073709551615e19 } diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index 07572a29851..1aab52f3b27 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -90,7 +90,7 @@ getTeamSizeImpl cfg tid = do result <- either (embed . throwIO . IndexLookupError) pure (r :: Either ES.EsError (ES.SearchResult UserDoc)) let aggs = fromMaybe mempty (ES.aggregations result) getCount name = maybe 0 (.filterDocCount) $ M.lookup name aggs >>= parseMaybe (parseJSON @FilterResult) - pure $ TeamSize (getCount "regulars") (getCount "apps") + pure $ TeamSize (getCount "teamSize") (getCount "apps") (getCount "collaborators") where teamQ = termQ "team" (idToText tid) diff --git a/libs/wire-subsystems/src/Wire/TeamJournal.hs b/libs/wire-subsystems/src/Wire/TeamJournal.hs index 9ae5ec1044a..bd65923296d 100644 --- a/libs/wire-subsystems/src/Wire/TeamJournal.hs +++ b/libs/wire-subsystems/src/Wire/TeamJournal.hs @@ -112,14 +112,13 @@ journalEvent typ tid dat tim = do -- utils evData :: TeamSize -> [UserId] -> Maybe Currency.Alpha -> TeamEvent'EventData -evData teamSize@(TeamSize regulars apps) billingUserIds cur = - defMessage - & T.memberCount .~ memberCountTotal - & T.billingUser .~ (toBytes <$> billingUserIds) - & T.maybe'currency .~ (pack . show <$> cur) - & T.memberCountRegular .~ memberCountRegulars - & T.memberCountApp .~ memberCountApps - where - memberCountTotal, memberCountRegulars, memberCountApps :: Int32 - (memberCountTotal, memberCountRegulars, memberCountApps) = - (fromIntegral $ teamSizeTotal teamSize, fromIntegral regulars, fromIntegral apps) +evData + (TeamSize (fromIntegral -> teamSize) (fromIntegral -> apps) (fromIntegral -> collaborators)) + billingUserIds + cur = + defMessage + & T.memberCount .~ teamSize + & T.billingUser .~ (toBytes <$> billingUserIds) + & T.maybe'currency .~ (pack . show <$> cur) + & T.apps .~ apps + & T.collaborators .~ collaborators diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index f78029cebde..a05edddfe72 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -313,8 +313,8 @@ internalFindTeamInvitationImpl (Just e) c = NotAllowed -> throwGuardFailed TeamInviteSetToNotAllowed maxSize <- maxTeamSize <$> input - teamSize <- teamSizeTotal <$> IndexedUserStore.getTeamSize tid - when (teamSize >= fromIntegral maxSize) $ + tSize <- (.teamSize) <$> IndexedUserStore.getTeamSize tid + when (tSize >= fromIntegral maxSize) $ throw UserSubsystemTooManyTeamMembers -- FUTUREWORK: The above can easily be done/tested in the intra call. -- Remove after the next release. diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs index b77869840fe..1fefcaeeb36 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/IndexedUserStore.hs @@ -87,12 +87,16 @@ inMemoryIndexedUserStoreInterpreter = error "IndexedUserStore: unimplemented in memory interpreter" GetTeamSize tid -> gets $ \index -> - let regulars = help [Just UserTypeRegular, Nothing] + let teamSize = help [Just UserTypeRegular, Nothing] apps = help [Just UserTypeApp] help allowedTypes = fromIntegral . length $ Map.filter (\(doc, _) -> doc.udTeam == Just tid && doc.udType `elem` allowedTypes) index.docs + collaborators = + fromIntegral + . length + $ Map.filter (\(doc, _) -> tid `elem` doc.udCollaboratingTeams) index.docs in TeamSize {..} upsertImpl :: (Member (State UserIndex) r) => ES.DocId -> UserDoc -> ES.VersionControl -> Sem r () diff --git a/services/brig/test/integration/API/Team.hs b/services/brig/test/integration/API/Team.hs index 66e1ead9e28..708fc1c7c46 100644 --- a/services/brig/test/integration/API/Team.hs +++ b/services/brig/test/integration/API/Team.hs @@ -154,7 +154,7 @@ testTeamSize brig req = do void $ get (req tid uid) Sem r () ensureNotTooLargeToActivateLegalHold tid = do - teamSize <- getSize tid - unlessM (teamSizeBelowLimit teamSize) $ + tSize <- (.teamSize) <$> getSize tid + unlessM (teamSizeBelowLimit tSize) $ throwS @'CannotEnableLegalHoldServiceLargeTeam teamSizeBelowLimit :: ( Member (Input FanoutLimit) r, Member (Input (FeatureDefaults LegalholdConfig)) r ) => - TeamSize -> + Natural -> Sem r Bool -teamSizeBelowLimit (fromIntegral . teamSizeTotal -> teamSize) = do - limit :: Int <- fromIntegral . fromRange <$> input @FanoutLimit +teamSizeBelowLimit teamSize = do + limit <- fromIntegral . fromRange <$> input @FanoutLimit let withinLimit = teamSize <= limit featureLegalHold <- input @(FeatureDefaults LegalholdConfig) case featureLegalHold of diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 181a720ae8f..59b86bf575d 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -79,6 +79,7 @@ import Galley.API.Teams.Notifications qualified as APITeamQueue import Galley.App import Galley.Types.Error as Galley import Imports hiding (forkIO) +import Numeric.Natural import Polysemy import Polysemy.Error import Polysemy.Input @@ -279,8 +280,8 @@ updateTeamStatus tid (TeamStatusUpdate newStatus cur) = do -- We could also write `updateTeamSize 1 size 0` here, but it seems clearer to do it -- inline. teamSize <- do - (TeamSize numRegulars numApps) <- E.getSize tid - pure $ TeamSize (max 1 numRegulars) numApps + (TeamSize numRegulars numApps numCollaborators) <- E.getSize tid + pure $ TeamSize (max 1 numRegulars) numApps numCollaborators Journal.teamActivate tid teamSize c teamCreationTime runJournal _ _ = throwS @'InvalidTeamStatusUpdate validateTransition :: (Member (ErrorS 'InvalidTeamStatusUpdate) r) => (TeamStatus, TeamStatus) -> Sem r Bool @@ -788,7 +789,9 @@ deleteTeamMember' lusr zcon tid remove mBody = do _ -> UserTypeFilterRegular teamSizeAfterDelete <- do before <- E.getSize tid - pure $ updateTeamSize uType before (-1) + pure case uType of + UserTypeFilterRegular -> before {teamSize = before.teamSize - 1} + UserTypeFilterApp -> before {apps = before.apps - 1} E.deleteUser remove case uType of UserTypeFilterRegular -> pure () @@ -1035,7 +1038,7 @@ ensureNotTooLargeForLegalHold :: Member FeaturesConfigSubsystem r ) => TeamId -> - TeamSize -> + Natural -> Sem r () ensureNotTooLargeForLegalHold tid teamSize = whenM (isLegalHoldEnabledForTeam tid) $ @@ -1073,8 +1076,10 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do E.getUser (new ^. userId) <&> \case Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp _ -> UserTypeFilterRegular - pure $ updateTeamSize uType n 1 - ensureNotTooLargeForLegalHold tid sizeAfterAdd + pure case uType of + UserTypeFilterRegular -> n {teamSize = n.teamSize + 1} + UserTypeFilterApp -> n {apps = n.apps + 1} + ensureNotTooLargeForLegalHold tid (sizeAfterAdd.teamSize + sizeAfterAdd.apps + sizeAfterAdd.collaborators) admins <- E.getTeamAdmins tid let admins' = [new ^. userId | isAdminOrOwner (new ^. M.permissions)] <> admins @@ -1110,10 +1115,10 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do Sem r TeamSize ensureNotTooLarge teamid = do o <- input - teamSize <- E.getSize teamid - unless (teamSizeTotal teamSize < fromIntegral (o ^. settings . maxTeamSize)) $ + tSize <- E.getSize teamid + unless (teamSize tSize < fromIntegral (o ^. settings . maxTeamSize)) $ throwS @'TooManyTeamMembers - pure teamSize + pure tSize getBindingTeamMembers :: ( Member (ErrorS 'TeamNotFound) r, @@ -1155,14 +1160,8 @@ canUserJoinTeam tid = do lhEnabled <- isLegalHoldEnabledForTeam tid when lhEnabled $ do sizeBeforeJoin <- E.getSize tid - let uType = - -- We do not have a `UserId` to check here. Also, - -- `canUserJoinTeam` is called by Brig during user - -- registration via invitation (POST /register), where apps - -- never go. So it is safe to assume "regular" - UserTypeFilterRegular - let sizeAfterJoin = updateTeamSize uType sizeBeforeJoin 1 - ensureNotTooLargeForLegalHold tid sizeAfterJoin + let sizeAfterJoin = sizeBeforeJoin {teamSize = sizeBeforeJoin.teamSize + 1} + ensureNotTooLargeForLegalHold tid (sizeAfterJoin.teamSize + sizeAfterJoin.apps + sizeAfterJoin.collaborators) -- | Modify and get visibility type for a team (internal, no user permission checks) getSearchVisibilityInternal :: From 529e2a7b68cd8cc497314d42427fb51d7aec4ba3 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 10:21:15 +0200 Subject: [PATCH 04/15] Guard legalhold team size limit when adding collaborators. --- .../src/Galley/API/Public/TeamMember.hs | 7 +++- services/galley/src/Galley/API/Teams.hs | 36 +++++++++++-------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index 5c56816012e..305c4fd9edc 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -20,6 +20,7 @@ module Galley.API.Public.TeamMember where import Galley.API.Teams import Galley.API.Teams.Export qualified as Export import Galley.App +import Imports import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Team.Collaborator @@ -36,7 +37,11 @@ teamMemberAPI = <@> mkNamedAPI @"update-team-member" updateTeamMember <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV <@> mkNamedAPI @"add-team-collaborator" - (\zuid tid (NewTeamCollaborator uid perms) -> createTeamCollaborator zuid uid tid perms) + ( \zuid tid (NewTeamCollaborator uid perms) -> do + n <- ensureNotTooLarge tid + ensureNotTooLargeForLegalHold tid (n.teamSize + n.apps + n.collaborators + 1) + createTeamCollaborator zuid uid tid perms + ) <@> mkNamedAPI @"get-team-collaborators" getAllTeamCollaborators <@> mkNamedAPI @"update-team-collaborator" updateTeamCollaborator <@> mkNamedAPI @"remove-team-collaborator" removeTeamCollaborator diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 59b86bf575d..352ac5fa37f 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -49,6 +49,7 @@ module Galley.API.Teams uncheckedUpdateTeamMember, userIsTeamOwner, canUserJoinTeam, + ensureNotTooLarge, ensureNotTooLargeForLegalHold, ensureNotTooLargeToActivateLegalHold, internalDeleteBindingTeam, @@ -1078,7 +1079,12 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do _ -> UserTypeFilterRegular pure case uType of UserTypeFilterRegular -> n {teamSize = n.teamSize + 1} - UserTypeFilterApp -> n {apps = n.apps + 1} + UserTypeFilterApp -> + -- FUTUREWORK: this shouldn't happen, apps are not team + -- members! See also: + -- https://wearezeta.atlassian.net/browse/WPB-28095 + -- https://wearezeta.atlassian.net/browse/WPB-25521 + n {apps = n.apps + 1} ensureNotTooLargeForLegalHold tid (sizeAfterAdd.teamSize + sizeAfterAdd.apps + sizeAfterAdd.collaborators) admins <- E.getTeamAdmins tid @@ -1105,20 +1111,20 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do APITeamQueue.pushTeamEvent tid e pure sizeAfterAdd - where - ensureNotTooLarge :: - ( Member E.BrigAPIAccess r, - Member (ErrorS 'TooManyTeamMembers) r, - Member (Input Opts) r - ) => - TeamId -> - Sem r TeamSize - ensureNotTooLarge teamid = do - o <- input - tSize <- E.getSize teamid - unless (teamSize tSize < fromIntegral (o ^. settings . maxTeamSize)) $ - throwS @'TooManyTeamMembers - pure tSize + +ensureNotTooLarge :: + ( Member E.BrigAPIAccess r, + Member (ErrorS 'TooManyTeamMembers) r, + Member (Input Opts) r + ) => + TeamId -> + Sem r TeamSize +ensureNotTooLarge teamid = do + o <- input + tSize <- E.getSize teamid + unless (teamSize tSize < fromIntegral (o ^. settings . maxTeamSize)) $ + throwS @'TooManyTeamMembers + pure tSize getBindingTeamMembers :: ( Member (ErrorS 'TeamNotFound) r, From 95f7ec2114dbb625b927674a21b06982904d396f Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 11:07:33 +0200 Subject: [PATCH 05/15] Refactor: move legalhold helpers to TeamSubsystem. --- .../API/Routes/Public/Galley/TeamMember.hs | 1 + .../wire-subsystems/src/Wire/TeamSubsystem.hs | 80 +++++++++++++++++++ .../galley/src/Galley/API/LegalHold/Team.hs | 37 +-------- .../src/Galley/API/Public/TeamMember.hs | 7 +- services/galley/src/Galley/API/Teams.hs | 49 +----------- 5 files changed, 93 insertions(+), 81 deletions(-) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs index 33044bfcc0a..e5d10d10977 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs @@ -213,6 +213,7 @@ type TeamMemberAPI = "add-team-collaborator" ( Summary "Add a collaborator to the team." :> From 'V10 + :> CanThrow 'TooManyTeamMembersOnTeamWithLegalhold :> ZLocalUser :> "teams" :> Capture "tid" TeamId diff --git a/libs/wire-subsystems/src/Wire/TeamSubsystem.hs b/libs/wire-subsystems/src/Wire/TeamSubsystem.hs index cd4fa9a7cac..8674077cde5 100644 --- a/libs/wire-subsystems/src/Wire/TeamSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/TeamSubsystem.hs @@ -26,13 +26,22 @@ import Data.Qualified import Data.Range import Data.Singletons (Demote, Sing, SingKind, fromSing) import Imports +import Numeric.Natural import Polysemy +import Polysemy.Input (Input, input) import Wire.API.Error import Wire.API.Error.Galley +import Wire.API.Team.Feature (FeatureStatus (FeatureStatusEnabled), LegalholdConfig) +import Wire.API.Team.FeatureFlags (FanoutLimit, FeatureDefaults (..)) import Wire.API.Team.LegalHold (UserLegalHoldStatusResponse) import Wire.API.Team.Member import Wire.API.Team.Member.Error import Wire.API.Team.Member.Info (TeamMemberInfoList) +import Wire.API.Team.Size (TeamSize (..)) +import Wire.BrigAPIAccess (BrigAPIAccess, getSize) +import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getDbFeatureRawInternal) +import Wire.LegalHold (computeLegalHoldFeatureStatus) +import Wire.LegalHoldStore (LegalHoldStore) data PermissionCheckArgs teamAssociation where PermissionCheckArgs :: @@ -144,3 +153,74 @@ checkConsent :: Sem r ConsentGiven checkConsent teamsOfUsers other = do consentGiven <$> getLHStatus (Map.lookup other teamsOfUsers) other + +-- | Ensure that a team has fewer members than the given limit (usually +-- @settings.maxTeamSize@). Returns the team size as it was before adding +-- anybody. +ensureNotTooLarge :: + ( Member BrigAPIAccess r, + Member (ErrorS 'TooManyTeamMembers) r + ) => + Word32 -> + TeamId -> + Sem r TeamSize +ensureNotTooLarge maxSize tid = do + tSize <- getSize tid + unless (tSize.teamSize < fromIntegral maxSize) $ + throwS @'TooManyTeamMembers + pure tSize + +-- | Ensure that a team doesn't exceed the member count limit for the LegalHold +-- feature. A team with more members than the fanout limit is too large, because +-- the fanout limit would prevent turning LegalHold feature _off_ again (for +-- details see 'Galley.API.LegalHold.removeSettings'). +-- +-- If LegalHold is configured for whitelisted teams only we consider the team +-- size unlimited, because we make the assumption that these teams won't turn +-- LegalHold off after activation. +-- FUTUREWORK: Find a way around the fanout limit. +ensureNotTooLargeForLegalHold :: + forall r. + ( Member LegalHoldStore r, + Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r, + Member (Input FanoutLimit) r, + Member (Input (FeatureDefaults LegalholdConfig)) r, + Member FeaturesConfigSubsystem r + ) => + TeamId -> + Natural -> + Sem r () +ensureNotTooLargeForLegalHold tid teamSize = + whenM (isLegalHoldEnabledForTeam tid) $ + unlessM (teamSizeBelowLimit teamSize) $ + throwS @'TooManyTeamMembersOnTeamWithLegalhold + +isLegalHoldEnabledForTeam :: + forall r. + ( Member LegalHoldStore r, + Member FeaturesConfigSubsystem r, + Member (Input (FeatureDefaults LegalholdConfig)) r + ) => + TeamId -> + Sem r Bool +isLegalHoldEnabledForTeam tid = do + dbFeature <- getDbFeatureRawInternal tid + status <- computeLegalHoldFeatureStatus tid dbFeature + pure $ status == FeatureStatusEnabled + +teamSizeBelowLimit :: + ( Member (Input FanoutLimit) r, + Member (Input (FeatureDefaults LegalholdConfig)) r + ) => + Natural -> + Sem r Bool +teamSizeBelowLimit teamSize = do + limit <- fromIntegral . fromRange <$> input @FanoutLimit + let withinLimit = teamSize <= limit + featureLegalHold <- input @(FeatureDefaults LegalholdConfig) + case featureLegalHold of + FeatureLegalHoldDisabledPermanently -> pure withinLimit + FeatureLegalHoldDisabledByDefault -> pure withinLimit + FeatureLegalHoldWhitelistTeamsAndImplicitConsent -> + -- unlimited, see docs of 'ensureNotTooLargeForLegalHold' + pure True diff --git a/services/galley/src/Galley/API/LegalHold/Team.hs b/services/galley/src/Galley/API/LegalHold/Team.hs index 100089cb303..aa208bb5310 100644 --- a/services/galley/src/Galley/API/LegalHold/Team.hs +++ b/services/galley/src/Galley/API/LegalHold/Team.hs @@ -28,12 +28,10 @@ where import Data.Code qualified as Code import Data.Id import Data.Misc (PlainTextPassword6) -import Data.Range import Imports -import Numeric.Natural import Polysemy import Polysemy.Error -import Polysemy.Input (Input, input) +import Polysemy.Input (Input) import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Team.Feature @@ -42,9 +40,10 @@ import Wire.API.Team.Size import Wire.API.User (VerificationAction) import Wire.API.User.Auth.ReAuth import Wire.BrigAPIAccess -import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getDbFeatureRawInternal) +import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem) import Wire.LegalHold import Wire.LegalHoldStore (LegalHoldStore) +import Wire.TeamSubsystem (isLegalHoldEnabledForTeam, teamSizeBelowLimit) assertLegalHoldEnabledForTeam :: forall r. @@ -59,19 +58,6 @@ assertLegalHoldEnabledForTeam tid = unlessM (isLegalHoldEnabledForTeam tid) $ throwS @'LegalHoldNotEnabled -isLegalHoldEnabledForTeam :: - forall r. - ( Member LegalHoldStore r, - Member FeaturesConfigSubsystem r, - Member (Input (FeatureDefaults LegalholdConfig)) r - ) => - TeamId -> - Sem r Bool -isLegalHoldEnabledForTeam tid = do - dbFeature <- getDbFeatureRawInternal tid - status <- computeLegalHoldFeatureStatus tid dbFeature - pure $ status == FeatureStatusEnabled - ensureNotTooLargeToActivateLegalHold :: ( Member BrigAPIAccess r, Member (ErrorS 'CannotEnableLegalHoldServiceLargeTeam) r, @@ -85,23 +71,6 @@ ensureNotTooLargeToActivateLegalHold tid = do unlessM (teamSizeBelowLimit tSize) $ throwS @'CannotEnableLegalHoldServiceLargeTeam -teamSizeBelowLimit :: - ( Member (Input FanoutLimit) r, - Member (Input (FeatureDefaults LegalholdConfig)) r - ) => - Natural -> - Sem r Bool -teamSizeBelowLimit teamSize = do - limit <- fromIntegral . fromRange <$> input @FanoutLimit - let withinLimit = teamSize <= limit - featureLegalHold <- input @(FeatureDefaults LegalholdConfig) - case featureLegalHold of - FeatureLegalHoldDisabledPermanently -> pure withinLimit - FeatureLegalHoldDisabledByDefault -> pure withinLimit - FeatureLegalHoldWhitelistTeamsAndImplicitConsent -> - -- unlimited, see docs of 'ensureNotTooLargeForLegalHold' - pure True - ensureReAuthorised :: ( Member BrigAPIAccess r, Member (Error AuthenticationError) r diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index 305c4fd9edc..87b467cbf0e 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -24,7 +24,10 @@ import Imports import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Team.Collaborator +import Wire.API.Team.Size +import Wire.BrigAPIAccess (getSize) import Wire.TeamCollaboratorsSubsystem +import Wire.TeamSubsystem qualified as TeamSubsystem teamMemberAPI :: API TeamMemberAPI GalleyEffects teamMemberAPI = @@ -38,8 +41,8 @@ teamMemberAPI = <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV <@> mkNamedAPI @"add-team-collaborator" ( \zuid tid (NewTeamCollaborator uid perms) -> do - n <- ensureNotTooLarge tid - ensureNotTooLargeForLegalHold tid (n.teamSize + n.apps + n.collaborators + 1) + n <- getSize tid + TeamSubsystem.ensureNotTooLargeForLegalHold tid (n.teamSize + n.apps + n.collaborators + 1) createTeamCollaborator zuid uid tid perms ) <@> mkNamedAPI @"get-team-collaborators" getAllTeamCollaborators diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 352ac5fa37f..50e98b84b1d 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -49,8 +49,6 @@ module Galley.API.Teams uncheckedUpdateTeamMember, userIsTeamOwner, canUserJoinTeam, - ensureNotTooLarge, - ensureNotTooLargeForLegalHold, ensureNotTooLargeToActivateLegalHold, internalDeleteBindingTeam, updateTeamCollaborator, @@ -80,7 +78,6 @@ import Galley.API.Teams.Notifications qualified as APITeamQueue import Galley.App import Galley.Types.Error as Galley import Imports hiding (forkIO) -import Numeric.Natural import Polysemy import Polysemy.Error import Polysemy.Input @@ -1021,31 +1018,6 @@ ensureNotElevated targetPermissions member = ) $ throwS @'InvalidPermissions --- | Ensure that a team doesn't exceed the member count limit for the LegalHold --- feature. A team with more members than the fanout limit is too large, because --- the fanout limit would prevent turning LegalHold feature _off_ again (for --- details see 'Galley.API.LegalHold.removeSettings'). --- --- If LegalHold is configured for whitelisted teams only we consider the team --- size unlimited, because we make the assumption that these teams won't turn --- LegalHold off after activation. --- FUTUREWORK: Find a way around the fanout limit. -ensureNotTooLargeForLegalHold :: - forall r. - ( Member LegalHoldStore r, - Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r, - Member (Input FanoutLimit) r, - Member (Input (FeatureDefaults LegalholdConfig)) r, - Member FeaturesConfigSubsystem r - ) => - TeamId -> - Natural -> - Sem r () -ensureNotTooLargeForLegalHold tid teamSize = - whenM (isLegalHoldEnabledForTeam tid) $ - unlessM (teamSizeBelowLimit teamSize) $ - throwS @'TooManyTeamMembersOnTeamWithLegalhold - addTeamMemberInternal :: ( Member E.BrigAPIAccess r, Member (ErrorS 'TooManyTeamMembers) r, @@ -1072,7 +1044,8 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do Log.field "targets" (toByteString (new ^. userId)) . Log.field "action" (Log.val "Teams.addTeamMemberInternal") sizeAfterAdd <- do - n <- ensureNotTooLarge tid + maxSize <- inputs @Opts (^. settings . maxTeamSize) + n <- TeamSubsystem.ensureNotTooLarge maxSize tid uType <- E.getUser (new ^. userId) <&> \case Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp @@ -1085,7 +1058,7 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do -- https://wearezeta.atlassian.net/browse/WPB-28095 -- https://wearezeta.atlassian.net/browse/WPB-25521 n {apps = n.apps + 1} - ensureNotTooLargeForLegalHold tid (sizeAfterAdd.teamSize + sizeAfterAdd.apps + sizeAfterAdd.collaborators) + TeamSubsystem.ensureNotTooLargeForLegalHold tid (sizeAfterAdd.teamSize + sizeAfterAdd.apps + sizeAfterAdd.collaborators) admins <- E.getTeamAdmins tid let admins' = [new ^. userId | isAdminOrOwner (new ^. M.permissions)] <> admins @@ -1112,20 +1085,6 @@ addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do APITeamQueue.pushTeamEvent tid e pure sizeAfterAdd -ensureNotTooLarge :: - ( Member E.BrigAPIAccess r, - Member (ErrorS 'TooManyTeamMembers) r, - Member (Input Opts) r - ) => - TeamId -> - Sem r TeamSize -ensureNotTooLarge teamid = do - o <- input - tSize <- E.getSize teamid - unless (teamSize tSize < fromIntegral (o ^. settings . maxTeamSize)) $ - throwS @'TooManyTeamMembers - pure tSize - getBindingTeamMembers :: ( Member (ErrorS 'TeamNotFound) r, Member (ErrorS 'NonBindingTeam) r, @@ -1167,7 +1126,7 @@ canUserJoinTeam tid = do when lhEnabled $ do sizeBeforeJoin <- E.getSize tid let sizeAfterJoin = sizeBeforeJoin {teamSize = sizeBeforeJoin.teamSize + 1} - ensureNotTooLargeForLegalHold tid (sizeAfterJoin.teamSize + sizeAfterJoin.apps + sizeAfterJoin.collaborators) + TeamSubsystem.ensureNotTooLargeForLegalHold tid (sizeAfterJoin.teamSize + sizeAfterJoin.apps + sizeAfterJoin.collaborators) -- | Modify and get visibility type for a team (internal, no user permission checks) getSearchVisibilityInternal :: From 75b8c3fb5c4d696f4dc66e5908e1c10b95330da6 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 11:18:44 +0200 Subject: [PATCH 06/15] Fix: team size query in ES. --- .../src/Wire/IndexedUserStore/ElasticSearch.hs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs index 1aab52f3b27..68e0f8a8649 100644 --- a/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs +++ b/libs/wire-subsystems/src/Wire/IndexedUserStore/ElasticSearch.hs @@ -119,13 +119,18 @@ getTeamSizeImpl cfg tid = do { ES.boolQueryMustMatch = [teamQ, termQ "type" "app"] } + -- Collaborators are not members of the team, they are users (of other teams + -- or of no team) that collaborate with it. + collaboratorQuery = termQ "collaborating_teams" (idToText tid) + search = (ES.mkSearch Nothing Nothing) { ES.size = ES.Size 0, ES.aggBody = Just $ - ES.mkAggregations "regulars" (ES.FilterAgg (ES.FilterAggregation (ES.Filter regularQuery) Nothing)) + ES.mkAggregations "teamSize" (ES.FilterAgg (ES.FilterAggregation (ES.Filter regularQuery) Nothing)) <> ES.mkAggregations "apps" (ES.FilterAgg (ES.FilterAggregation (ES.Filter appQuery) Nothing)) + <> ES.mkAggregations "collaborators" (ES.FilterAgg (ES.FilterAggregation (ES.Filter collaboratorQuery) Nothing)) } upsertImpl :: From cb7df80008008f9e56d448bed38d1277af28af86 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Thu, 27 Aug 2026 10:56:00 +0200 Subject: [PATCH 07/15] Polish swagger docs. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/wire-api/src/Wire/API/Team/Size.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/wire-api/src/Wire/API/Team/Size.hs b/libs/wire-api/src/Wire/API/Team/Size.hs index ba84e40039f..86467737075 100644 --- a/libs/wire-api/src/Wire/API/Team/Size.hs +++ b/libs/wire-api/src/Wire/API/Team/Size.hs @@ -39,7 +39,7 @@ data TeamSize = TeamSize instance ToSchema TeamSize where schema = - objectWithDocModifier (description ?~ "Number of team members (paid seats, always regular users), appps, collaborators.") $ + objectWithDocModifier (description ?~ "Team member counts: paid seats (regular users), apps, and collaborators.") $ TeamSize <$> (.teamSize) .= field "teamSize" schema <*> (.apps) .= field "apps" schema From abd69bccb372991c1179fc2f9a0a2f1a8c61587a Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 1 Sep 2026 10:03:04 +0200 Subject: [PATCH 08/15] Fix integration test. --- integration/test/Test/Apps.hs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index 91080552b96..fae5255955c 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -579,30 +579,29 @@ testAppReceivesMemberJoinNotification = do testTeamSizeWithApps :: (HasCallStack) => TaggedBool "test internal api" -> App () testTeamSizeWithApps (TaggedBool testInternalApi) = do domain <- make OwnDomain - numRegulars <- liftIO $ randomRIO (1 :: Int, 3) + numRegulars <- liftIO $ randomRIO (2 :: Int, 4) numApps <- liftIO $ randomRIO (1 :: Int, 3) - (owner, tid, extraMembers) <- createTeam domain (numRegulars + 1) + (owner, tid, extraMembers) <- createTeam domain numRegulars apps <- replicateM numApps $ bindResponse (createApp owner tid def) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "user" let checkSize :: (HasCallStack) => Int -> Int -> App () - checkSize wantRegulars wantApps = - (if testInternalApi then BrigI.getTeamSize else Brig.getTeamSize) owner tid `bindResponse` \resp -> do - resp.status `shouldMatchInt` 200 - resp.json %. "teamSize" `shouldMatchInt` (1 + wantRegulars + wantApps) - resp.json %. "teamSizeRegulars" `shouldMatchInt` (1 + wantRegulars) - resp.json %. "teamSizeApps" `shouldMatchInt` wantApps + checkSize wantRegulars wantApps = do + BrigI.refreshIndex domain + eventually $ do + (if testInternalApi then BrigI.getTeamSize else Brig.getTeamSize) owner tid `bindResponse` \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "teamSize" `shouldMatchInt` wantRegulars + resp.json %. "apps" `shouldMatchInt` wantApps + resp.json %. "collaborators" `shouldMatchInt` 0 - BrigI.refreshIndex domain - eventually $ do - checkSize numRegulars numApps + checkSize numRegulars numApps deleteTeamMember tid owner (head apps) >>= assertSuccess - deleteTeamMember tid owner (head extraMembers) >>= assertSuccess + checkSize numRegulars (numApps - 1) - BrigI.refreshIndex domain - eventually $ do - checkSize (numRegulars - 1) (numApps - 1) + deleteTeamMember tid owner (head extraMembers) >>= assertSuccess + checkSize (numRegulars - 1) (numApps - 1) From 8351ec83f8226cc30cb3bdcbfc71b337091c9e85 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 1 Sep 2026 11:13:21 +0200 Subject: [PATCH 09/15] Fix ES synchronization issue. --- integration/test/Test/Apps.hs | 1 + services/galley/src/Galley/API/Teams.hs | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/integration/test/Test/Apps.hs b/integration/test/Test/Apps.hs index fae5255955c..fffa1376106 100644 --- a/integration/test/Test/Apps.hs +++ b/integration/test/Test/Apps.hs @@ -196,6 +196,7 @@ testDeleteAppFromTeam = do appId <- bindResponse (createApp owner tid new) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "user.id" & asString + BrigI.refreshIndex domain let appIdObject = object ["domain" .= domain, "id" .= appId] diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 50e98b84b1d..f94ca1a68b4 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -786,7 +786,20 @@ deleteTeamMember' lusr zcon tid remove mBody = do Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp _ -> UserTypeFilterRegular teamSizeAfterDelete <- do - before <- E.getSize tid + before <- + -- ES may not be in sync with cassandra/postgres, eg., if we + -- add and remove a member very quickly. So, if we call + -- E.getSize here, we get the wrong answer, and this may + -- result in the `TeamSize` naturals to underflow (5xx error). + -- + -- Two solutions: (1) force-sync the index here (it doesn't + -- drift, the approximate value is only used for the + -- journal); (2) accept that `E.getSize` gives us an + -- approximation and circumvent the 5xx errors by + -- lower-bounding the fields before the substraction. + -- + -- We apply (2). + E.getSize tid <&> \s -> s {teamSize = min 1 s.teamSize, apps = min 1 s.apps} pure case uType of UserTypeFilterRegular -> before {teamSize = before.teamSize - 1} UserTypeFilterApp -> before {apps = before.apps - 1} From eb494f50c36724c26882b03c28a21bfe7bd49bb7 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Tue, 1 Sep 2026 23:09:40 +0200 Subject: [PATCH 10/15] Make protobuf decs more clear. --- libs/types-common-journal/proto/TeamEvents.proto | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/types-common-journal/proto/TeamEvents.proto b/libs/types-common-journal/proto/TeamEvents.proto index 0552704b7a4..6e2aed96213 100644 --- a/libs/types-common-journal/proto/TeamEvents.proto +++ b/libs/types-common-journal/proto/TeamEvents.proto @@ -22,7 +22,9 @@ message TeamEvent { // are guaranteed to be present). // // for backwards compatibility, clients should make these - // fields optional, and assume '0' if missing. + // fields optional, and assume '0' if missing. wire-server + // always sets these fields and never receives team events + // from clients, so we make the fields as "required". required int32 apps = 4; required int32 collaborators = 5; } From 739a97611742e6af17455c2b9bbc060f336c0d2d Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 2 Sep 2026 08:56:13 +0200 Subject: [PATCH 11/15] Do not mix up min, max, they are quite different! --- services/galley/src/Galley/API/Teams.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index f94ca1a68b4..85a8c2f45bf 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -799,7 +799,7 @@ deleteTeamMember' lusr zcon tid remove mBody = do -- lower-bounding the fields before the substraction. -- -- We apply (2). - E.getSize tid <&> \s -> s {teamSize = min 1 s.teamSize, apps = min 1 s.apps} + E.getSize tid <&> \s -> s {teamSize = max 1 s.teamSize, apps = max 1 s.apps} pure case uType of UserTypeFilterRegular -> before {teamSize = before.teamSize - 1} UserTypeFilterApp -> before {apps = before.apps - 1} From 66a91b850a61fb441f3d2d10e43049e85fb349a6 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 2 Sep 2026 08:58:06 +0200 Subject: [PATCH 12/15] [experiment] move handler logic leaked in galley to subsystem. This is adding wayyy too much code for handling the unnatural separation between brig and galley. The changes in this commit are only half the story, now we'd need to start adding galley effects to brig. [See also](https://github.com/wireapp/wire-server/pull/5495). --- .../src/Wire/TeamCollaboratorsSubsystem.hs | 2 +- .../TeamCollaboratorsSubsystem/Interpreter.hs | 28 +++++++++-- .../test/unit/Wire/MiniBackend.hs | 50 +++++++++++++++++-- .../Wire/MockInterpreters/BrigAPIAccess.hs | 3 +- .../InterpreterSpec.hs | 16 +++--- .../src/Galley/API/Public/TeamMember.hs | 8 +-- services/galley/src/Galley/App.hs | 2 + 7 files changed, 84 insertions(+), 25 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs index 6d1bed860ff..792361661cf 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs @@ -26,7 +26,7 @@ import Polysemy import Wire.API.Team.Collaborator data TeamCollaboratorsSubsystem m a where - CreateTeamCollaborator :: Local UserId -> UserId -> TeamId -> Set CollaboratorPermission -> TeamCollaboratorsSubsystem m () + CreateTeamCollaborator :: Local UserId -> TeamId -> NewTeamCollaborator -> TeamCollaboratorsSubsystem m () GetAllTeamCollaborators :: Local UserId -> TeamId -> TeamCollaboratorsSubsystem m [TeamCollaborator] InternalGetTeamCollaborator :: TeamId -> UserId -> TeamCollaboratorsSubsystem m (Maybe TeamCollaborator) InternalGetTeamCollaborations :: UserId -> TeamCollaboratorsSubsystem m [TeamCollaborator] diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs index bb0541636b0..590913cc408 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs @@ -24,14 +24,21 @@ import Data.Set qualified as Set import Imports import Polysemy import Polysemy.Error +import Polysemy.Input import Wire.API.Error import Wire.API.Error.Brig qualified as E +import Wire.API.Error.Galley import Wire.API.Event.Team import Wire.API.Team.Collaborator +import Wire.API.Team.Feature +import Wire.API.Team.FeatureFlags import Wire.API.Team.Member qualified as TeamMember +import Wire.API.Team.Size (TeamSize (..)) import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.BrigAPIAccess qualified as BrigAPIAccess import Wire.Error +import Wire.FeaturesConfigSubsystem +import Wire.LegalHoldStore import Wire.NotificationSubsystem import Wire.Sem.Now import Wire.TeamCollaboratorsStore qualified as Store @@ -41,6 +48,11 @@ import Wire.TeamSubsystem.Util interpretTeamCollaboratorsSubsystem :: ( Member TeamSubsystem r, + Member (Input FanoutLimit) r, + Member (Input (FeatureDefaults LegalholdConfig)) r, + Member (ErrorS TooManyTeamMembersOnTeamWithLegalhold) r, + Member LegalHoldStore r, + Member FeaturesConfigSubsystem r, Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, @@ -51,7 +63,7 @@ interpretTeamCollaboratorsSubsystem :: interpretTeamCollaboratorsSubsystem brigAPIAccess = interpret $ brigAPIAccess . \case - CreateTeamCollaborator zUser user team perms -> createTeamCollaboratorImpl zUser user team perms + CreateTeamCollaborator zUser team new -> createTeamCollaboratorImpl zUser team new GetAllTeamCollaborators zUser team -> getAllTeamCollaboratorsImpl zUser team InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId @@ -76,6 +88,11 @@ internalGetTeamCollaborationsImpl userId = do createTeamCollaboratorImpl :: ( Member TeamSubsystem r, + Member (Input FanoutLimit) r, + Member (Input (FeatureDefaults LegalholdConfig)) r, + Member (ErrorS TooManyTeamMembersOnTeamWithLegalhold) r, + Member LegalHoldStore r, + Member FeaturesConfigSubsystem r, Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, @@ -83,14 +100,15 @@ createTeamCollaboratorImpl :: Member BrigAPIAccess r ) => Local UserId -> - UserId -> TeamId -> - Set CollaboratorPermission -> + NewTeamCollaborator -> Sem r () -createTeamCollaboratorImpl zUser user team perms = do +createTeamCollaboratorImpl zUser team (NewTeamCollaborator user perms) = do guardPermission (tUnqualified zUser) team TeamMember.GetTeamCollaborators InsufficientRights - Store.createTeamCollaborator user team perms + BrigAPIAccess.getSize team >>= \n -> + ensureNotTooLargeForLegalHold team (n.teamSize + n.apps + n.collaborators + 1) + Store.createTeamCollaborator user team perms generateTeamEvents (tUnqualified zUser) team [EdCollaboratorAdd user (Set.toList perms)] -- Reindex the collaborator with their new collaboration team diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index ff27a27fa7f..c961e681b21 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -80,13 +80,14 @@ import Type.Reflection import Wire.API.Allowlists (AllowlistEmailDomains) import Wire.API.Conversation.Config (ConversationSubsystemConfig (..)) import Wire.API.Error (ErrorS) -import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) +import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound, TooManyTeamMembersOnTeamWithLegalhold)) import Wire.API.Federation.API import Wire.API.Federation.Component import Wire.API.Federation.Error import Wire.API.Password import Wire.API.Team.Collaborator import Wire.API.Team.Feature +import Wire.API.Team.FeatureFlags (FanoutLimit, FeatureDefaults, defaultFanoutLimit) import Wire.API.Team.Member hiding (userId) import Wire.API.User as User hiding (DeleteUser) import Wire.API.User.Activation (ActivationCode) @@ -112,14 +113,16 @@ import Wire.DeleteQueue.InMemory import Wire.DomainRegistrationStore qualified as DRS import Wire.EmailSubsystem (EmailSubsystem) import Wire.Events +import Wire.FeaturesConfigSubsystem import Wire.FederationAPIAccess import Wire.FederationAPIAccess.Interpreter as FI import Wire.FederationConfigStore -import Wire.GalleyAPIAccess +import Wire.GalleyAPIAccess hiding (GetAllTeamFeaturesForUser) import Wire.HashPassword (HashPassword) import Wire.IndexedUserStore import Wire.InternalEvent hiding (DeleteUser) import Wire.InvitationStore +import Wire.LegalHoldStore import Wire.MlsKeyPackageSubsystem import Wire.MockInterpreters import Wire.NotificationSubsystem @@ -300,7 +303,10 @@ type MiniBackendLowerEffects = Random, Now, ErrorS 'TeamMemberNotFound, - ErrorS 'TeamNotFound + ErrorS 'TeamNotFound, + LegalHoldStore, + FeaturesConfigSubsystem, + ErrorS 'TooManyTeamMembersOnTeamWithLegalhold ] `Append` InputEffects `Append` '[ Metrics @@ -325,6 +331,10 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . ignoreMetrics . inputEffectsInterpreters usrCfg appCfg localBackend.teamIdps . fmap (either (error . show) Imports.id) + . runError @(Tagged 'TooManyTeamMembersOnTeamWithLegalhold ()) + . mockFeaturesConfigSubsystem galleyConfigs + . mockLegalHoldStore + . fmap (either (error . show) Imports.id) . runError @(Tagged 'TeamNotFound ()) . fmap (either (error . show) Imports.id) . runError @(Tagged 'TeamMemberNotFound ()) @@ -386,6 +396,36 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = HasMlsKeyPackagesBulk {} -> pure mempty _ -> error "Unimplemented MlsKeyPackageSubsystem operation in mock" + mockLegalHoldStore :: forall r'. InterpreterFor LegalHoldStore r' + mockLegalHoldStore = interpret $ \case + CreateSettings _ -> pure () + GetSettings _ -> pure Nothing + RemoveSettings _ -> pure () + InsertPendingPrekeys _ _ -> pure () + SelectPendingPrekeys _ -> pure Nothing + DropPendingPrekeys _ -> pure () + SetUserLegalHoldStatus {} -> pure () + SetTeamLegalholdWhitelisted _ -> pure () + UnsetTeamLegalholdWhitelisted _ -> pure () + IsTeamLegalholdWhitelisted _ -> pure False + _ -> error "Unimplemented LegalHoldStore operation in mock" + + mockFeaturesConfigSubsystem :: AllTeamFeatures -> InterpreterFor FeaturesConfigSubsystem r' + mockFeaturesConfigSubsystem configs = interpret $ \case + GetDbFeatureRawInternal _ -> pure def + GetFeature _ _ -> pure def + GetFeatureForTeam _ -> pure $ npProject configs + GetFeatureForServer -> pure def + GetFeatureForTeamUser _ _ -> pure def + GetAllTeamFeaturesForTeamMember _ _ -> pure configs + GetAllTeamFeaturesForTeam _ -> pure configs + GetAllTeamFeaturesForServer -> pure configs + GuardSecondFactorDisabled _ _ -> pure () + FeatureEnabledForTeam _ _ -> pure True + GetAllTeamFeaturesForUser _ -> pure configs + GetSingleFeatureForUser _ -> pure def + GetFeatureInternal _ -> pure def + type StateEffects = '[ State [Push], State (Map (TeamId) [TeamCollaborator]), @@ -436,6 +476,8 @@ type InputEffects = Input VerificationCodeThrottleTTL, Input AuthenticationSubsystemConfig, Input ClientSubsystemConfig, + Input FanoutLimit, + Input (FeatureDefaults LegalholdConfig), Input (Local ()) ] @@ -492,6 +534,8 @@ inputEffectsInterpreters :: Sem r a inputEffectsInterpreters usrCfg appCfg teamIdps = runInputConst defaultLocalDomain + . runInputConst (def @(FeatureDefaults LegalholdConfig)) + . runInputConst defaultFanoutLimit . runInputConst defaultClientSubsystemConfig . runInputConst defaultAuthenticationSubsystemConfig . runInputConst (VerificationCodeThrottleTTL 60) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs index aeb76299093..e7ceeb631dc 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs @@ -19,12 +19,14 @@ module Wire.MockInterpreters.BrigAPIAccess where import Imports import Polysemy +import Wire.API.Team.Size import Wire.BrigAPIAccess -- | Errors out on everything except 'UpdateSearchIndex', which is a no-op. mockBrigAPIAccess :: InterpreterFor BrigAPIAccess r mockBrigAPIAccess = interpret $ \case UpdateSearchIndex _ -> pure () + GetSize {} -> pure (TeamSize 1 0 0) -- everything else is not implemented GetConnectionsUnqualified {} -> error "GetConnectionsUnqualified: implement on demand (mockBrigAPIAccess)" GetConnections {} -> error "GetConnections: implement on demand (mockBrigAPIAccess)" @@ -34,7 +36,6 @@ mockBrigAPIAccess = interpret $ \case GetUsers {} -> error "GetUsers: implement on demand (mockBrigAPIAccess)" DeleteUser {} -> error "DeleteUser: implement on demand (mockBrigAPIAccess)" GetContactList {} -> error "GetContactList: implement on demand (mockBrigAPIAccess)" - GetSize {} -> error "GetSize: implement on demand (mockBrigAPIAccess)" LookupClients {} -> error "LookupClients: implement on demand (mockBrigAPIAccess)" LookupClientsFull {} -> error "LookupClientsFull: implement on demand (mockBrigAPIAccess)" NotifyClientsAboutLegalHoldRequest {} -> error "NotifyClientsAboutLegalHoldRequest: implement on demand (mockBrigAPIAccess)" diff --git a/libs/wire-subsystems/test/unit/Wire/TeamCollaboratorsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/TeamCollaboratorsSubsystem/InterpreterSpec.hs index 4cc7d59cb32..f917d8cdda2 100644 --- a/libs/wire-subsystems/test/unit/Wire/TeamCollaboratorsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/TeamCollaboratorsSubsystem/InterpreterSpec.hs @@ -28,7 +28,7 @@ import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck import Wire.API.Team.Collaborator -import Wire.API.Team.Member +import Wire.API.Team.Member hiding (NewTeamCollaborator) import Wire.API.Team.Role import Wire.MiniBackend import Wire.MockInterpreters.Error @@ -53,7 +53,7 @@ spec = do teamMap = Map.singleton tid [ownerTeamMember] in runNoFederationStack localBackend teamMap config $ do - createTeamCollaborator authUser collaborator.id tid collabPerms + createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms) collaborators <- getAllTeamCollaborators authUser tid pure $ collaborators === [TeamCollaborator collaborator.id tid collabPerms] @@ -92,7 +92,7 @@ spec = do teamMap config $ catchExpectedError @TeamCollaboratorsError - (createTeamCollaborator authUser collaborator.id tid collabPerms) + (createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms)) pure $ res === InsufficientRights prop "getting fails if the caller has insufficient permissions" $ @@ -120,7 +120,7 @@ spec = do teamMap config $ do - createTeamCollaborator eligibleAuthUser collaborator.id tid collabPerms + createTeamCollaborator eligibleAuthUser tid (NewTeamCollaborator collaborator.id collabPerms) catchExpectedError @TeamCollaboratorsError $ getAllTeamCollaborators nonEligibleAuthUser tid pure $ res === InsufficientRights @@ -138,7 +138,7 @@ spec = do res <- runNoFederationStack localBackend teamMap config $ catchExpectedError @TeamCollaboratorsError - (createTeamCollaborator authUser collaborator.id tid collabPerms) + (createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms)) pure $ res === InsufficientRights prop "getting fails if team does not exist" $ @@ -175,7 +175,7 @@ spec = do in runNoFederationStack localBackend teamMap config $ do conjoin <$$> forM (Map.keys collaboratorTeams) $ \(collaborator :: StoredUser) -> do forM_ (collaboratorTeams Map.! collaborator) \tid -> - createTeamCollaborator authUser collaborator.id tid collabPerms + createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms) collaborators <- internalGetTeamCollaborations collaborator.id let collaboratorTids = Set.fromList $ map gTeam collaborators expectedCollaboratorTids = collaboratorTeams Map.! collaborator @@ -201,7 +201,7 @@ spec = do in runNoFederationStack localBackend teamMap config $ do forM_ (Map.keys collaboratorTeams) $ \(collaborator :: StoredUser) -> forM_ (collaboratorTeams Map.! collaborator) \tid -> - createTeamCollaborator authUser collaborator.id tid collabPerms + createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms) collaborators <- internalGetTeamCollaboratorsWithIds (Set.fromList (concat (Map.elems collaboratorTeams))) @@ -226,7 +226,7 @@ spec = do in runNoFederationStack localBackend teamMap config $ do forM_ (Map.keys collaboratorTeams) $ \(collaborator :: StoredUser) -> forM_ (collaboratorTeams Map.! collaborator) \tid -> - createTeamCollaborator authUser collaborator.id tid collabPerms + createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms) collaborators <- internalGetTeamCollaboratorsWithIds (Set.fromList (concat (Map.elems collaboratorTeams))) diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index 87b467cbf0e..524c88cac05 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -25,7 +25,6 @@ import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Team.Collaborator import Wire.API.Team.Size -import Wire.BrigAPIAccess (getSize) import Wire.TeamCollaboratorsSubsystem import Wire.TeamSubsystem qualified as TeamSubsystem @@ -39,12 +38,7 @@ teamMemberAPI = <@> mkNamedAPI @"delete-non-binding-team-member" deleteNonBindingTeamMember <@> mkNamedAPI @"update-team-member" updateTeamMember <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV - <@> mkNamedAPI @"add-team-collaborator" - ( \zuid tid (NewTeamCollaborator uid perms) -> do - n <- getSize tid - TeamSubsystem.ensureNotTooLargeForLegalHold tid (n.teamSize + n.apps + n.collaborators + 1) - createTeamCollaborator zuid uid tid perms - ) + <@> mkNamedAPI @"add-team-collaborator" createTeamCollaborator <@> mkNamedAPI @"get-team-collaborators" getAllTeamCollaborators <@> mkNamedAPI @"update-team-collaborator" updateTeamCollaborator <@> mkNamedAPI @"remove-team-collaborator" removeTeamCollaborator diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index f47dc7f3798..3ce2997c654 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -259,6 +259,7 @@ type GalleyEffects = ErrorS 'AccessDenied, ErrorS 'TeamMemberNotFound, ErrorS 'HistoryNotSupported, + ErrorS 'TooManyTeamMembersOnTeamWithLegalhold, ErrorS 'NotATeamMember, ErrorS 'ConvAccessDenied, ErrorS 'NotConnected, @@ -516,6 +517,7 @@ evalGalley e = . mapError toResponse -- ErrorS 'NotConnected . mapError toResponse -- ErrorS 'ConvAccessDenied . mapError toResponse -- ErrorS 'NotATeamMember + . mapError toResponse -- ErrorS 'TooManyTeamMembersOnTeamWithLegalhold . mapError toResponse -- ErrorS 'HistoryNotSupported . mapError toResponse -- ErrorS 'TeamMemberNotFound . mapError toResponse -- ErrorS 'AccessDenied From 946948684cdd8b2e66b8caba5db534443f4c5c54 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 2 Sep 2026 09:01:05 +0200 Subject: [PATCH 13/15] Revert "[experiment] move handler logic leaked in galley to subsystem." This reverts commit 66a91b850a61fb441f3d2d10e43049e85fb349a6. --- .../src/Wire/TeamCollaboratorsSubsystem.hs | 2 +- .../TeamCollaboratorsSubsystem/Interpreter.hs | 28 ++--------- .../test/unit/Wire/MiniBackend.hs | 50 ++----------------- .../Wire/MockInterpreters/BrigAPIAccess.hs | 3 +- .../InterpreterSpec.hs | 16 +++--- .../src/Galley/API/Public/TeamMember.hs | 8 ++- services/galley/src/Galley/App.hs | 2 - 7 files changed, 25 insertions(+), 84 deletions(-) diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs index 792361661cf..6d1bed860ff 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem.hs @@ -26,7 +26,7 @@ import Polysemy import Wire.API.Team.Collaborator data TeamCollaboratorsSubsystem m a where - CreateTeamCollaborator :: Local UserId -> TeamId -> NewTeamCollaborator -> TeamCollaboratorsSubsystem m () + CreateTeamCollaborator :: Local UserId -> UserId -> TeamId -> Set CollaboratorPermission -> TeamCollaboratorsSubsystem m () GetAllTeamCollaborators :: Local UserId -> TeamId -> TeamCollaboratorsSubsystem m [TeamCollaborator] InternalGetTeamCollaborator :: TeamId -> UserId -> TeamCollaboratorsSubsystem m (Maybe TeamCollaborator) InternalGetTeamCollaborations :: UserId -> TeamCollaboratorsSubsystem m [TeamCollaborator] diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs index 590913cc408..bb0541636b0 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsSubsystem/Interpreter.hs @@ -24,21 +24,14 @@ import Data.Set qualified as Set import Imports import Polysemy import Polysemy.Error -import Polysemy.Input import Wire.API.Error import Wire.API.Error.Brig qualified as E -import Wire.API.Error.Galley import Wire.API.Event.Team import Wire.API.Team.Collaborator -import Wire.API.Team.Feature -import Wire.API.Team.FeatureFlags import Wire.API.Team.Member qualified as TeamMember -import Wire.API.Team.Size (TeamSize (..)) import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.BrigAPIAccess qualified as BrigAPIAccess import Wire.Error -import Wire.FeaturesConfigSubsystem -import Wire.LegalHoldStore import Wire.NotificationSubsystem import Wire.Sem.Now import Wire.TeamCollaboratorsStore qualified as Store @@ -48,11 +41,6 @@ import Wire.TeamSubsystem.Util interpretTeamCollaboratorsSubsystem :: ( Member TeamSubsystem r, - Member (Input FanoutLimit) r, - Member (Input (FeatureDefaults LegalholdConfig)) r, - Member (ErrorS TooManyTeamMembersOnTeamWithLegalhold) r, - Member LegalHoldStore r, - Member FeaturesConfigSubsystem r, Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, @@ -63,7 +51,7 @@ interpretTeamCollaboratorsSubsystem :: interpretTeamCollaboratorsSubsystem brigAPIAccess = interpret $ brigAPIAccess . \case - CreateTeamCollaborator zUser team new -> createTeamCollaboratorImpl zUser team new + CreateTeamCollaborator zUser user team perms -> createTeamCollaboratorImpl zUser user team perms GetAllTeamCollaborators zUser team -> getAllTeamCollaboratorsImpl zUser team InternalGetTeamCollaborator team user -> internalGetTeamCollaboratorImpl team user InternalGetTeamCollaborations userId -> internalGetTeamCollaborationsImpl userId @@ -88,11 +76,6 @@ internalGetTeamCollaborationsImpl userId = do createTeamCollaboratorImpl :: ( Member TeamSubsystem r, - Member (Input FanoutLimit) r, - Member (Input (FeatureDefaults LegalholdConfig)) r, - Member (ErrorS TooManyTeamMembersOnTeamWithLegalhold) r, - Member LegalHoldStore r, - Member FeaturesConfigSubsystem r, Member (Error TeamCollaboratorsError) r, Member Store.TeamCollaboratorsStore r, Member Now r, @@ -100,15 +83,14 @@ createTeamCollaboratorImpl :: Member BrigAPIAccess r ) => Local UserId -> + UserId -> TeamId -> - NewTeamCollaborator -> + Set CollaboratorPermission -> Sem r () -createTeamCollaboratorImpl zUser team (NewTeamCollaborator user perms) = do +createTeamCollaboratorImpl zUser user team perms = do guardPermission (tUnqualified zUser) team TeamMember.GetTeamCollaborators InsufficientRights - BrigAPIAccess.getSize team >>= \n -> - ensureNotTooLargeForLegalHold team (n.teamSize + n.apps + n.collaborators + 1) - Store.createTeamCollaborator user team perms + generateTeamEvents (tUnqualified zUser) team [EdCollaboratorAdd user (Set.toList perms)] -- Reindex the collaborator with their new collaboration team diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index c961e681b21..ff27a27fa7f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -80,14 +80,13 @@ import Type.Reflection import Wire.API.Allowlists (AllowlistEmailDomains) import Wire.API.Conversation.Config (ConversationSubsystemConfig (..)) import Wire.API.Error (ErrorS) -import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound, TooManyTeamMembersOnTeamWithLegalhold)) +import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) import Wire.API.Federation.API import Wire.API.Federation.Component import Wire.API.Federation.Error import Wire.API.Password import Wire.API.Team.Collaborator import Wire.API.Team.Feature -import Wire.API.Team.FeatureFlags (FanoutLimit, FeatureDefaults, defaultFanoutLimit) import Wire.API.Team.Member hiding (userId) import Wire.API.User as User hiding (DeleteUser) import Wire.API.User.Activation (ActivationCode) @@ -113,16 +112,14 @@ import Wire.DeleteQueue.InMemory import Wire.DomainRegistrationStore qualified as DRS import Wire.EmailSubsystem (EmailSubsystem) import Wire.Events -import Wire.FeaturesConfigSubsystem import Wire.FederationAPIAccess import Wire.FederationAPIAccess.Interpreter as FI import Wire.FederationConfigStore -import Wire.GalleyAPIAccess hiding (GetAllTeamFeaturesForUser) +import Wire.GalleyAPIAccess import Wire.HashPassword (HashPassword) import Wire.IndexedUserStore import Wire.InternalEvent hiding (DeleteUser) import Wire.InvitationStore -import Wire.LegalHoldStore import Wire.MlsKeyPackageSubsystem import Wire.MockInterpreters import Wire.NotificationSubsystem @@ -303,10 +300,7 @@ type MiniBackendLowerEffects = Random, Now, ErrorS 'TeamMemberNotFound, - ErrorS 'TeamNotFound, - LegalHoldStore, - FeaturesConfigSubsystem, - ErrorS 'TooManyTeamMembersOnTeamWithLegalhold + ErrorS 'TeamNotFound ] `Append` InputEffects `Append` '[ Metrics @@ -331,10 +325,6 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . ignoreMetrics . inputEffectsInterpreters usrCfg appCfg localBackend.teamIdps . fmap (either (error . show) Imports.id) - . runError @(Tagged 'TooManyTeamMembersOnTeamWithLegalhold ()) - . mockFeaturesConfigSubsystem galleyConfigs - . mockLegalHoldStore - . fmap (either (error . show) Imports.id) . runError @(Tagged 'TeamNotFound ()) . fmap (either (error . show) Imports.id) . runError @(Tagged 'TeamMemberNotFound ()) @@ -396,36 +386,6 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = HasMlsKeyPackagesBulk {} -> pure mempty _ -> error "Unimplemented MlsKeyPackageSubsystem operation in mock" - mockLegalHoldStore :: forall r'. InterpreterFor LegalHoldStore r' - mockLegalHoldStore = interpret $ \case - CreateSettings _ -> pure () - GetSettings _ -> pure Nothing - RemoveSettings _ -> pure () - InsertPendingPrekeys _ _ -> pure () - SelectPendingPrekeys _ -> pure Nothing - DropPendingPrekeys _ -> pure () - SetUserLegalHoldStatus {} -> pure () - SetTeamLegalholdWhitelisted _ -> pure () - UnsetTeamLegalholdWhitelisted _ -> pure () - IsTeamLegalholdWhitelisted _ -> pure False - _ -> error "Unimplemented LegalHoldStore operation in mock" - - mockFeaturesConfigSubsystem :: AllTeamFeatures -> InterpreterFor FeaturesConfigSubsystem r' - mockFeaturesConfigSubsystem configs = interpret $ \case - GetDbFeatureRawInternal _ -> pure def - GetFeature _ _ -> pure def - GetFeatureForTeam _ -> pure $ npProject configs - GetFeatureForServer -> pure def - GetFeatureForTeamUser _ _ -> pure def - GetAllTeamFeaturesForTeamMember _ _ -> pure configs - GetAllTeamFeaturesForTeam _ -> pure configs - GetAllTeamFeaturesForServer -> pure configs - GuardSecondFactorDisabled _ _ -> pure () - FeatureEnabledForTeam _ _ -> pure True - GetAllTeamFeaturesForUser _ -> pure configs - GetSingleFeatureForUser _ -> pure def - GetFeatureInternal _ -> pure def - type StateEffects = '[ State [Push], State (Map (TeamId) [TeamCollaborator]), @@ -476,8 +436,6 @@ type InputEffects = Input VerificationCodeThrottleTTL, Input AuthenticationSubsystemConfig, Input ClientSubsystemConfig, - Input FanoutLimit, - Input (FeatureDefaults LegalholdConfig), Input (Local ()) ] @@ -534,8 +492,6 @@ inputEffectsInterpreters :: Sem r a inputEffectsInterpreters usrCfg appCfg teamIdps = runInputConst defaultLocalDomain - . runInputConst (def @(FeatureDefaults LegalholdConfig)) - . runInputConst defaultFanoutLimit . runInputConst defaultClientSubsystemConfig . runInputConst defaultAuthenticationSubsystemConfig . runInputConst (VerificationCodeThrottleTTL 60) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs index e7ceeb631dc..aeb76299093 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BrigAPIAccess.hs @@ -19,14 +19,12 @@ module Wire.MockInterpreters.BrigAPIAccess where import Imports import Polysemy -import Wire.API.Team.Size import Wire.BrigAPIAccess -- | Errors out on everything except 'UpdateSearchIndex', which is a no-op. mockBrigAPIAccess :: InterpreterFor BrigAPIAccess r mockBrigAPIAccess = interpret $ \case UpdateSearchIndex _ -> pure () - GetSize {} -> pure (TeamSize 1 0 0) -- everything else is not implemented GetConnectionsUnqualified {} -> error "GetConnectionsUnqualified: implement on demand (mockBrigAPIAccess)" GetConnections {} -> error "GetConnections: implement on demand (mockBrigAPIAccess)" @@ -36,6 +34,7 @@ mockBrigAPIAccess = interpret $ \case GetUsers {} -> error "GetUsers: implement on demand (mockBrigAPIAccess)" DeleteUser {} -> error "DeleteUser: implement on demand (mockBrigAPIAccess)" GetContactList {} -> error "GetContactList: implement on demand (mockBrigAPIAccess)" + GetSize {} -> error "GetSize: implement on demand (mockBrigAPIAccess)" LookupClients {} -> error "LookupClients: implement on demand (mockBrigAPIAccess)" LookupClientsFull {} -> error "LookupClientsFull: implement on demand (mockBrigAPIAccess)" NotifyClientsAboutLegalHoldRequest {} -> error "NotifyClientsAboutLegalHoldRequest: implement on demand (mockBrigAPIAccess)" diff --git a/libs/wire-subsystems/test/unit/Wire/TeamCollaboratorsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/TeamCollaboratorsSubsystem/InterpreterSpec.hs index f917d8cdda2..4cc7d59cb32 100644 --- a/libs/wire-subsystems/test/unit/Wire/TeamCollaboratorsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/TeamCollaboratorsSubsystem/InterpreterSpec.hs @@ -28,7 +28,7 @@ import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck import Wire.API.Team.Collaborator -import Wire.API.Team.Member hiding (NewTeamCollaborator) +import Wire.API.Team.Member import Wire.API.Team.Role import Wire.MiniBackend import Wire.MockInterpreters.Error @@ -53,7 +53,7 @@ spec = do teamMap = Map.singleton tid [ownerTeamMember] in runNoFederationStack localBackend teamMap config $ do - createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms) + createTeamCollaborator authUser collaborator.id tid collabPerms collaborators <- getAllTeamCollaborators authUser tid pure $ collaborators === [TeamCollaborator collaborator.id tid collabPerms] @@ -92,7 +92,7 @@ spec = do teamMap config $ catchExpectedError @TeamCollaboratorsError - (createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms)) + (createTeamCollaborator authUser collaborator.id tid collabPerms) pure $ res === InsufficientRights prop "getting fails if the caller has insufficient permissions" $ @@ -120,7 +120,7 @@ spec = do teamMap config $ do - createTeamCollaborator eligibleAuthUser tid (NewTeamCollaborator collaborator.id collabPerms) + createTeamCollaborator eligibleAuthUser collaborator.id tid collabPerms catchExpectedError @TeamCollaboratorsError $ getAllTeamCollaborators nonEligibleAuthUser tid pure $ res === InsufficientRights @@ -138,7 +138,7 @@ spec = do res <- runNoFederationStack localBackend teamMap config $ catchExpectedError @TeamCollaboratorsError - (createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms)) + (createTeamCollaborator authUser collaborator.id tid collabPerms) pure $ res === InsufficientRights prop "getting fails if team does not exist" $ @@ -175,7 +175,7 @@ spec = do in runNoFederationStack localBackend teamMap config $ do conjoin <$$> forM (Map.keys collaboratorTeams) $ \(collaborator :: StoredUser) -> do forM_ (collaboratorTeams Map.! collaborator) \tid -> - createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms) + createTeamCollaborator authUser collaborator.id tid collabPerms collaborators <- internalGetTeamCollaborations collaborator.id let collaboratorTids = Set.fromList $ map gTeam collaborators expectedCollaboratorTids = collaboratorTeams Map.! collaborator @@ -201,7 +201,7 @@ spec = do in runNoFederationStack localBackend teamMap config $ do forM_ (Map.keys collaboratorTeams) $ \(collaborator :: StoredUser) -> forM_ (collaboratorTeams Map.! collaborator) \tid -> - createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms) + createTeamCollaborator authUser collaborator.id tid collabPerms collaborators <- internalGetTeamCollaboratorsWithIds (Set.fromList (concat (Map.elems collaboratorTeams))) @@ -226,7 +226,7 @@ spec = do in runNoFederationStack localBackend teamMap config $ do forM_ (Map.keys collaboratorTeams) $ \(collaborator :: StoredUser) -> forM_ (collaboratorTeams Map.! collaborator) \tid -> - createTeamCollaborator authUser tid (NewTeamCollaborator collaborator.id collabPerms) + createTeamCollaborator authUser collaborator.id tid collabPerms collaborators <- internalGetTeamCollaboratorsWithIds (Set.fromList (concat (Map.elems collaboratorTeams))) diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index 524c88cac05..87b467cbf0e 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -25,6 +25,7 @@ import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Team.Collaborator import Wire.API.Team.Size +import Wire.BrigAPIAccess (getSize) import Wire.TeamCollaboratorsSubsystem import Wire.TeamSubsystem qualified as TeamSubsystem @@ -38,7 +39,12 @@ teamMemberAPI = <@> mkNamedAPI @"delete-non-binding-team-member" deleteNonBindingTeamMember <@> mkNamedAPI @"update-team-member" updateTeamMember <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV - <@> mkNamedAPI @"add-team-collaborator" createTeamCollaborator + <@> mkNamedAPI @"add-team-collaborator" + ( \zuid tid (NewTeamCollaborator uid perms) -> do + n <- getSize tid + TeamSubsystem.ensureNotTooLargeForLegalHold tid (n.teamSize + n.apps + n.collaborators + 1) + createTeamCollaborator zuid uid tid perms + ) <@> mkNamedAPI @"get-team-collaborators" getAllTeamCollaborators <@> mkNamedAPI @"update-team-collaborator" updateTeamCollaborator <@> mkNamedAPI @"remove-team-collaborator" removeTeamCollaborator diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index 3ce2997c654..f47dc7f3798 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -259,7 +259,6 @@ type GalleyEffects = ErrorS 'AccessDenied, ErrorS 'TeamMemberNotFound, ErrorS 'HistoryNotSupported, - ErrorS 'TooManyTeamMembersOnTeamWithLegalhold, ErrorS 'NotATeamMember, ErrorS 'ConvAccessDenied, ErrorS 'NotConnected, @@ -517,7 +516,6 @@ evalGalley e = . mapError toResponse -- ErrorS 'NotConnected . mapError toResponse -- ErrorS 'ConvAccessDenied . mapError toResponse -- ErrorS 'NotATeamMember - . mapError toResponse -- ErrorS 'TooManyTeamMembersOnTeamWithLegalhold . mapError toResponse -- ErrorS 'HistoryNotSupported . mapError toResponse -- ErrorS 'TeamMemberNotFound . mapError toResponse -- ErrorS 'AccessDenied From 3c3c6c9f863439d3788c30a8c32e07a1c01084c9 Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Wed, 2 Sep 2026 10:36:21 +0200 Subject: [PATCH 14/15] De-flake integration test. --- integration/test/Test/TeamCollaborators.hs | 1 + 1 file changed, 1 insertion(+) diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs index 96192faaa72..45642d45e3e 100644 --- a/integration/test/Test/TeamCollaborators.hs +++ b/integration/test/Test/TeamCollaborators.hs @@ -64,6 +64,7 @@ testCreateTeamCollaborator = do res %. "permissions" `shouldMatch` ["create_team_conversation", "implicit_connection"] -- team size counts collaborators separately + BrigI.refreshIndex OwnDomain bindResponse (getTeamSize owner team) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "teamSize" `shouldMatchInt` 2 From 307fe2bd7e63d8069b0e783c07087702ca89662c Mon Sep 17 00:00:00 2001 From: Matthias Fischmann Date: Mon, 7 Sep 2026 08:55:52 +0200 Subject: [PATCH 15/15] Fix logic error in deleteTeamMember' Co-authored-by: Leif Battermann --- services/galley/src/Galley/API/Teams.hs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 85a8c2f45bf..e84a0275da8 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -799,7 +799,10 @@ deleteTeamMember' lusr zcon tid remove mBody = do -- lower-bounding the fields before the substraction. -- -- We apply (2). - E.getSize tid <&> \s -> s {teamSize = max 1 s.teamSize, apps = max 1 s.apps} + E.getSize tid <&> \s -> case uType of + UserTypeFilterRegular -> s {teamSize = max 1 s.teamSize} + UserTypeFilterApp -> s {apps = max 1 s.apps} + pure case uType of UserTypeFilterRegular -> before {teamSize = before.teamSize - 1} UserTypeFilterApp -> before {apps = before.apps - 1}